@bsv/sdk 2.0.2 → 2.0.4

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.
Files changed (60) hide show
  1. package/dist/cjs/package.json +1 -1
  2. package/dist/cjs/src/auth/Peer.js +35 -33
  3. package/dist/cjs/src/auth/Peer.js.map +1 -1
  4. package/dist/cjs/src/auth/clients/AuthFetch.js +31 -5
  5. package/dist/cjs/src/auth/clients/AuthFetch.js.map +1 -1
  6. package/dist/cjs/src/auth/clients/__tests__/AuthFetch.test.js +77 -0
  7. package/dist/cjs/src/auth/clients/__tests__/AuthFetch.test.js.map +1 -1
  8. package/dist/cjs/src/auth/transports/SimplifiedFetchTransport.js +4 -1
  9. package/dist/cjs/src/auth/transports/SimplifiedFetchTransport.js.map +1 -1
  10. package/dist/cjs/src/identity/ContactsManager.js +26 -14
  11. package/dist/cjs/src/identity/ContactsManager.js.map +1 -1
  12. package/dist/cjs/src/identity/IdentityClient.js +12 -8
  13. package/dist/cjs/src/identity/IdentityClient.js.map +1 -1
  14. package/dist/cjs/src/overlay-tools/LookupResolver.js +85 -41
  15. package/dist/cjs/src/overlay-tools/LookupResolver.js.map +1 -1
  16. package/dist/cjs/src/overlay-tools/SHIPBroadcaster.js +28 -4
  17. package/dist/cjs/src/overlay-tools/SHIPBroadcaster.js.map +1 -1
  18. package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
  19. package/dist/esm/src/auth/Peer.js +35 -33
  20. package/dist/esm/src/auth/Peer.js.map +1 -1
  21. package/dist/esm/src/auth/clients/AuthFetch.js +31 -5
  22. package/dist/esm/src/auth/clients/AuthFetch.js.map +1 -1
  23. package/dist/esm/src/auth/clients/__tests__/AuthFetch.test.js +77 -0
  24. package/dist/esm/src/auth/clients/__tests__/AuthFetch.test.js.map +1 -1
  25. package/dist/esm/src/auth/transports/SimplifiedFetchTransport.js +4 -1
  26. package/dist/esm/src/auth/transports/SimplifiedFetchTransport.js.map +1 -1
  27. package/dist/esm/src/identity/ContactsManager.js +26 -14
  28. package/dist/esm/src/identity/ContactsManager.js.map +1 -1
  29. package/dist/esm/src/identity/IdentityClient.js +12 -8
  30. package/dist/esm/src/identity/IdentityClient.js.map +1 -1
  31. package/dist/esm/src/overlay-tools/LookupResolver.js +85 -41
  32. package/dist/esm/src/overlay-tools/LookupResolver.js.map +1 -1
  33. package/dist/esm/src/overlay-tools/SHIPBroadcaster.js +29 -4
  34. package/dist/esm/src/overlay-tools/SHIPBroadcaster.js.map +1 -1
  35. package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
  36. package/dist/types/src/auth/Peer.d.ts.map +1 -1
  37. package/dist/types/src/auth/clients/AuthFetch.d.ts.map +1 -1
  38. package/dist/types/src/auth/transports/SimplifiedFetchTransport.d.ts.map +1 -1
  39. package/dist/types/src/identity/ContactsManager.d.ts.map +1 -1
  40. package/dist/types/src/identity/IdentityClient.d.ts.map +1 -1
  41. package/dist/types/src/overlay-tools/LookupResolver.d.ts +6 -0
  42. package/dist/types/src/overlay-tools/LookupResolver.d.ts.map +1 -1
  43. package/dist/types/src/overlay-tools/SHIPBroadcaster.d.ts +8 -0
  44. package/dist/types/src/overlay-tools/SHIPBroadcaster.d.ts.map +1 -1
  45. package/dist/types/tsconfig.types.tsbuildinfo +1 -1
  46. package/dist/umd/bundle.js +1 -1
  47. package/dist/umd/bundle.js.map +1 -1
  48. package/package.json +1 -1
  49. package/src/auth/Peer.ts +49 -47
  50. package/src/auth/__tests/Peer.test.ts +35 -30
  51. package/src/auth/clients/AuthFetch.ts +46 -18
  52. package/src/auth/clients/__tests__/AuthFetch.test.ts +97 -0
  53. package/src/auth/transports/SimplifiedFetchTransport.ts +24 -21
  54. package/src/identity/ContactsManager.ts +27 -15
  55. package/src/identity/IdentityClient.ts +13 -11
  56. package/src/identity/__tests/IdentityClient.test.ts +2 -2
  57. package/src/overlay-tools/LookupResolver.ts +83 -46
  58. package/src/overlay-tools/SHIPBroadcaster.ts +32 -4
  59. package/src/overlay-tools/__tests/LookupResolver.test.ts +36 -55
  60. package/src/overlay-tools/__tests/SHIPBroadcaster.test.ts +18 -6
@@ -3,7 +3,7 @@
3
3
  import { AuthMessage, RequestedCertificateSet, Transport } from '../types.js'
4
4
  import * as Utils from '../../primitives/utils.js'
5
5
 
6
- const defaultFetch: typeof fetch =
6
+ const defaultFetch: typeof fetch =
7
7
  typeof globalThis !== 'undefined' && typeof globalThis.fetch === 'function'
8
8
  ? globalThis.fetch.bind(globalThis)
9
9
  : fetch
@@ -22,7 +22,7 @@ export class SimplifiedFetchTransport implements Transport {
22
22
  * @param baseUrl - The base URL for all HTTP requests made by this transport.
23
23
  * @param fetchClient - A fetch implementation to use for HTTP requests (default: global fetch).
24
24
  */
25
- constructor (baseUrl: string, fetchClient = defaultFetch) {
25
+ constructor(baseUrl: string, fetchClient = defaultFetch) {
26
26
  if (typeof fetchClient !== 'function') {
27
27
  throw new Error(
28
28
  'SimplifiedFetchTransport requires a fetch implementation. ' +
@@ -44,7 +44,7 @@ export class SimplifiedFetchTransport implements Transport {
44
44
  *
45
45
  * @throws Will throw an error if no listener has been registered via `onData`.
46
46
  */
47
- async send (message: AuthMessage): Promise<void> {
47
+ async send(message: AuthMessage): Promise<void> {
48
48
  if (this.onDataCallback == null) {
49
49
  throw new Error('Listen before you start speaking. God gave you two ears and one mouth for a reason.')
50
50
  }
@@ -240,24 +240,27 @@ export class SimplifiedFetchTransport implements Transport {
240
240
  * @param callback - A function to invoke when an incoming AuthMessage is received.
241
241
  * @returns A promise that resolves once the callback is set.
242
242
  */
243
- async onData (callback: (message: AuthMessage) => Promise<void>): Promise<void> {
243
+ async onData(callback: (message: AuthMessage) => Promise<void>): Promise<void> {
244
244
  this.onDataCallback = (m) => {
245
- void callback(m)
245
+ void callback(m).catch(() => {
246
+ // Errors from handleIncomingMessage on the client side are not
247
+ // actionable here — prevent unhandled promise rejections.
248
+ })
246
249
  }
247
250
  }
248
251
 
249
- private createNetworkError (url: string, originalError: unknown): Error {
252
+ private createNetworkError(url: string, originalError: unknown): Error {
250
253
  const baseMessage = `Network error while sending authenticated request to ${url}`
251
254
  if (originalError instanceof Error) {
252
255
  const error = new Error(`${baseMessage}: ${originalError.message}`)
253
256
  error.stack = originalError.stack
254
- ;(error as any).cause = originalError
257
+ ; (error as any).cause = originalError
255
258
  return error
256
259
  }
257
260
  return new Error(`${baseMessage}: ${String(originalError)}`)
258
261
  }
259
262
 
260
- private createUnauthenticatedResponseError (
263
+ private createUnauthenticatedResponseError(
261
264
  url: string,
262
265
  response: Response,
263
266
  bodyBytes: number[],
@@ -277,17 +280,17 @@ export class SimplifiedFetchTransport implements Transport {
277
280
  }
278
281
 
279
282
  const error = new Error(parts.join(' - '))
280
- ;(error as any).details = {
281
- url,
282
- status: response.status,
283
- statusText: response.statusText,
284
- missingHeaders,
285
- bodyPreview
286
- }
283
+ ; (error as any).details = {
284
+ url,
285
+ status: response.status,
286
+ statusText: response.statusText,
287
+ missingHeaders,
288
+ bodyPreview
289
+ }
287
290
  return error
288
291
  }
289
292
 
290
- private createMalformedHeaderError (
293
+ private createMalformedHeaderError(
291
294
  url: string,
292
295
  headerName: string,
293
296
  headerValue: string,
@@ -297,13 +300,13 @@ export class SimplifiedFetchTransport implements Transport {
297
300
  if (cause instanceof Error) {
298
301
  const error = new Error(`${errorMessage}. ${cause.message}`)
299
302
  error.stack = cause.stack
300
- ;(error as any).cause = cause
303
+ ; (error as any).cause = cause
301
304
  return error
302
305
  }
303
306
  return new Error(`${errorMessage}. ${String(cause)}`)
304
307
  }
305
308
 
306
- private getBodyPreview (bodyBytes: number[], contentType: string | null): string | undefined {
309
+ private getBodyPreview(bodyBytes: number[], contentType: string | null): string | undefined {
307
310
  if (bodyBytes.length === 0) {
308
311
  return undefined
309
312
  }
@@ -333,7 +336,7 @@ export class SimplifiedFetchTransport implements Transport {
333
336
  return preview
334
337
  }
335
338
 
336
- private isTextualContent (contentType: string | null, sample: number[]): boolean {
339
+ private isTextualContent(contentType: string | null, sample: number[]): boolean {
337
340
  if (sample.length === 0) {
338
341
  return false
339
342
  }
@@ -367,7 +370,7 @@ export class SimplifiedFetchTransport implements Transport {
367
370
  return (printableCount / sample.length) > 0.8
368
371
  }
369
372
 
370
- private formatBinaryPreview (bytes: number[], truncated: boolean): string {
373
+ private formatBinaryPreview(bytes: number[], truncated: boolean): string {
371
374
  const hex = bytes.map(byte => byte.toString(16).padStart(2, '0')).join('')
372
375
  return `0x${hex}${truncated ? '…' : ''}`
373
376
  }
@@ -379,7 +382,7 @@ export class SimplifiedFetchTransport implements Transport {
379
382
  * @returns An object representing the deserialized request, including the method,
380
383
  * URL postfix (path and query string), headers, body, and request ID.
381
384
  */
382
- deserializeRequestPayload (payload: number[]): {
385
+ deserializeRequestPayload(payload: number[]): {
383
386
  method: string
384
387
  urlPostfix: string
385
388
  headers: Record<string, string>
@@ -88,33 +88,45 @@ export class ContactsManager {
88
88
  return []
89
89
  }
90
90
 
91
- const contacts: Contact[] = []
91
+ // Pre-process outputs synchronously to extract decode data, then decrypt in parallel
92
+ const decryptTasks: Array<{ keyID: string, ciphertext: number[] }> = []
92
93
 
93
- // Process each contact output
94
94
  for (const output of outputs.outputs) {
95
95
  try {
96
96
  if (output.lockingScript == null) continue
97
-
98
- // Decode the PushDrop data
99
97
  const decoded = PushDrop.decode(LockingScript.fromHex(output.lockingScript))
100
98
  if (output.customInstructions == null) continue
101
99
  const keyID = JSON.parse(output.customInstructions).keyID
100
+ decryptTasks.push({ keyID, ciphertext: decoded.fields[0] })
101
+ } catch (error) {
102
+ console.warn('ContactsManager: Failed to decode contact output:', error)
103
+ }
104
+ }
102
105
 
103
- // Decrypt the contact data
104
- const { plaintext } = await this.wallet.decrypt({
105
- ciphertext: decoded.fields[0],
106
+ // Decrypt all contacts in parallel — each call is a network round-trip over localhost
107
+ const decryptResults = await Promise.allSettled(
108
+ decryptTasks.map(async task =>
109
+ await this.wallet.decrypt({
110
+ ciphertext: task.ciphertext,
106
111
  protocolID: CONTACT_PROTOCOL_ID,
107
- keyID,
112
+ keyID: task.keyID,
108
113
  counterparty: 'self'
109
114
  }, this.originator)
115
+ )
116
+ )
110
117
 
111
- // Parse the contact data
112
- const contactData: Contact = JSON.parse(Utils.toUTF8(plaintext))
113
-
114
- contacts.push(contactData)
115
- } catch (error) {
116
- console.warn('ContactsManager: Failed to decode contact output:', error)
117
- // Skip this contact and continue with others
118
+ const contacts: Contact[] = []
119
+ for (let i = 0; i < decryptResults.length; i++) {
120
+ const result = decryptResults[i]
121
+ if (result.status === 'fulfilled') {
122
+ try {
123
+ const contactData: Contact = JSON.parse(Utils.toUTF8(result.value.plaintext))
124
+ contacts.push(contactData)
125
+ } catch (error) {
126
+ console.warn('ContactsManager: Failed to parse contact data:', error)
127
+ }
128
+ } else {
129
+ console.warn('ContactsManager: Failed to decrypt contact output:', result.reason)
118
130
  }
119
131
  }
120
132
 
@@ -138,19 +138,21 @@ export class IdentityClient {
138
138
  args: DiscoverByIdentityKeyArgs,
139
139
  overrideWithContacts = true
140
140
  ): Promise<DisplayableIdentity[]> {
141
- if (overrideWithContacts) {
142
- // Override results with personal contacts if available
143
- const contacts = await this.contactsManager.getContacts(args.identityKey)
144
- if (contacts.length > 0) {
145
- return contacts
146
- }
141
+ // Run both queries in parallel for better performance
142
+ const [contacts, certificatesResult] = await Promise.all([
143
+ overrideWithContacts
144
+ ? this.contactsManager.getContacts(args.identityKey)
145
+ : Promise.resolve([]),
146
+ this.wallet.discoverByIdentityKey(args, this.originator)
147
+ ])
148
+
149
+ // Override results with personal contacts if available
150
+ if (contacts.length > 0) {
151
+ return contacts
147
152
  }
148
153
 
149
- const { certificates } = await this.wallet.discoverByIdentityKey(
150
- args,
151
- this.originator
152
- )
153
- return certificates.map((cert) => {
154
+ const certs = certificatesResult?.certificates ?? []
155
+ return certs.map((cert) => {
154
156
  return IdentityClient.parseIdentity(cert)
155
157
  })
156
158
  }
@@ -264,8 +264,8 @@ describe('IdentityClient', () => {
264
264
 
265
265
  expect(identities).toHaveLength(1)
266
266
  expect(identities[0].name).toBe('Alice Smith (Personal Contact)') // Contact should be returned, not discovered identity
267
- // Wallet method should not be called when contact is found
268
- expect(walletMock.discoverByIdentityKey).not.toHaveBeenCalled()
267
+ // Both calls fire in parallel, but contacts take priority in the result
268
+ expect(walletMock.discoverByIdentityKey).toHaveBeenCalled()
269
269
  })
270
270
  })
271
271
 
@@ -264,7 +264,6 @@ export default class LookupResolver {
264
264
  competentHosts = await this.getCompetentHostsCached(question.service)
265
265
  }
266
266
  if (this.additionalHosts[question.service]?.length > 0) {
267
- // preserve order: resolved hosts first, then additional (unique)
268
267
  const extra = this.additionalHosts[question.service]
269
268
  const seen = new Set(competentHosts)
270
269
  for (const h of extra) if (!seen.has(h)) competentHosts.push(h)
@@ -283,28 +282,47 @@ export default class LookupResolver {
283
282
  throw new Error(`All competent hosts for ${question.service} are temporarily unavailable due to backoff.`)
284
283
  }
285
284
 
286
- // Fire all hosts with per-host timeout, harvest successful output-list responses
287
- const hostResponses = await Promise.allSettled(
288
- rankedHosts.map(async (host) => {
289
- return await this.lookupHostWithTracking(host, question, timeout)
290
- })
291
- )
285
+ // Fire all hosts; resolve as soon as we have results from any host,
286
+ // then allow a short grace window for additional hosts to contribute.
287
+ const GRACE_MS = 200
288
+ const answers: LookupAnswer[] = await new Promise<LookupAnswer[]>((resolve) => {
289
+ const collected: LookupAnswer[] = []
290
+ let pending = rankedHosts.length
291
+ let graceTimer: ReturnType<typeof setTimeout> | null = null
292
+
293
+ const tryResolve = (): void => {
294
+ if (graceTimer !== null) clearTimeout(graceTimer)
295
+ resolve(collected)
296
+ }
297
+
298
+ for (const host of rankedHosts) {
299
+ this.lookupHostWithTracking(host, question, timeout)
300
+ .then((answer) => {
301
+ if (answer?.type === 'output-list' && Array.isArray(answer.outputs) && answer.outputs.length > 0) {
302
+ collected.push(answer)
303
+ // First valid response: start a grace window for others
304
+ if (collected.length === 1 && pending > 1) {
305
+ graceTimer = setTimeout(tryResolve, GRACE_MS)
306
+ }
307
+ }
308
+ })
309
+ .catch(() => { /* host failed; tracked by lookupHostWithTracking */ })
310
+ .finally(() => {
311
+ pending--
312
+ if (pending === 0) tryResolve()
313
+ })
314
+ }
315
+ })
292
316
 
293
317
  const outputsMap = new Map<string, { beef: number[], context?: number[], outputIndex: number }>()
294
318
 
295
319
  // Memo key helper for tx parsing
296
320
  const beefKey = (beef: number[]): string => {
297
321
  if (typeof beef !== 'object') return '' // The invalid BEEF has an empty key.
298
- // A fast and deterministic key for memoization; avoids large JSON strings
299
- // since beef is an array of integers, join is safe and compact.
300
322
  return beef.join(',')
301
323
  }
302
324
 
303
- for (const result of hostResponses) {
304
- if (result.status !== 'fulfilled') continue
305
- const response = result.value
306
- if (response?.type !== 'output-list' || !Array.isArray(response.outputs)) continue
307
-
325
+ for (const response of answers) {
308
326
  for (const output of response.outputs) {
309
327
  const keyForBeef = beefKey(output.beef)
310
328
  let memo = this.txMemo.get(keyForBeef)
@@ -313,7 +331,6 @@ export default class LookupResolver {
313
331
  try {
314
332
  const txId = Transaction.fromBEEF(output.beef).id('hex')
315
333
  memo = { txId, expiresAt: now + this.txMemoTtlMs }
316
- // prune opportunistically if the map gets too large (cheap heuristic)
317
334
  if (this.txMemo.size > 4096) this.evictOldest(this.txMemo)
318
335
  this.txMemo.set(keyForBeef, memo)
319
336
  } catch {
@@ -322,7 +339,6 @@ export default class LookupResolver {
322
339
  }
323
340
 
324
341
  const uniqKey = `${memo.txId}.${output.outputIndex}`
325
- // last-writer wins is fine here; outputs are identical if uniqKey matches
326
342
  outputsMap.set(uniqKey, output)
327
343
  }
328
344
  }
@@ -391,8 +407,33 @@ export default class LookupResolver {
391
407
  return hosts
392
408
  }
393
409
 
410
+ /**
411
+ * Extracts competent host domains from a SLAP tracker response.
412
+ */
413
+ private extractHostsFromAnswer (answer: LookupAnswer, service: string): string[] {
414
+ const hosts: string[] = []
415
+ if (answer.type !== 'output-list') return hosts
416
+ for (const output of answer.outputs) {
417
+ try {
418
+ const tx = Transaction.fromBEEF(output.beef)
419
+ const script = tx.outputs[output.outputIndex]?.lockingScript
420
+ if (typeof script !== 'object' || script === null) continue
421
+ const parsed = OverlayAdminTokenTemplate.decode(script)
422
+ if (parsed.topicOrService !== service || parsed.protocol !== 'SLAP') continue
423
+ if (typeof parsed.domain === 'string' && parsed.domain.length > 0) {
424
+ hosts.push(parsed.domain)
425
+ }
426
+ } catch {
427
+ continue
428
+ }
429
+ }
430
+ return hosts
431
+ }
432
+
394
433
  /**
395
434
  * Returns a list of competent hosts for a given lookup service.
435
+ * Resolves as soon as the first SLAP tracker responds with valid hosts.
436
+ * Remaining trackers continue in the background for reputation tracking.
396
437
  * @param service Service for which competent hosts are to be returned
397
438
  * @returns Array of hosts competent for resolving queries
398
439
  */
@@ -402,43 +443,39 @@ export default class LookupResolver {
402
443
  query: { service }
403
444
  }
404
445
 
405
- // Query all SLAP trackers; tolerate failures.
406
446
  const trackerHosts = this.prepareHostsForQuery(
407
447
  this.slapTrackers,
408
448
  'SLAP trackers'
409
449
  )
410
450
  if (trackerHosts.length === 0) return []
411
451
 
412
- const trackerResponses = await Promise.allSettled(
413
- trackerHosts.map(async (tracker) =>
414
- await this.lookupHostWithTracking(tracker, query, MAX_TRACKER_WAIT_TIME)
415
- )
416
- )
417
-
418
- const hosts = new Set<string>()
419
-
420
- for (const result of trackerResponses) {
421
- if (result.status !== 'fulfilled') continue
422
- const answer = result.value
423
- if (answer.type !== 'output-list') continue
424
-
425
- for (const output of answer.outputs) {
426
- try {
427
- const tx = Transaction.fromBEEF(output.beef)
428
- const script = tx.outputs[output.outputIndex]?.lockingScript
429
- if (typeof script !== 'object' || script === null) continue
430
- const parsed = OverlayAdminTokenTemplate.decode(script)
431
- if (parsed.topicOrService !== service || parsed.protocol !== 'SLAP') continue
432
- if (typeof parsed.domain === 'string' && parsed.domain.length > 0) {
433
- hosts.add(parsed.domain)
434
- }
435
- } catch {
436
- continue
437
- }
452
+ // Fire all trackers, resolve as soon as any returns valid hosts.
453
+ // Remaining trackers continue in the background for reputation tracking.
454
+ return await new Promise<string[]>((resolve) => {
455
+ const allHosts = new Set<string>()
456
+ let resolved = false
457
+ let pending = trackerHosts.length
458
+
459
+ for (const tracker of trackerHosts) {
460
+ this.lookupHostWithTracking(tracker, query, MAX_TRACKER_WAIT_TIME)
461
+ .then((answer) => {
462
+ const hosts = this.extractHostsFromAnswer(answer, service)
463
+ for (const h of hosts) allHosts.add(h)
464
+ if (!resolved && allHosts.size > 0) {
465
+ resolved = true
466
+ resolve([...allHosts])
467
+ }
468
+ })
469
+ .catch(() => { /* tracker failed; tracked by lookupHostWithTracking */ })
470
+ .finally(() => {
471
+ pending--
472
+ if (pending === 0 && !resolved) {
473
+ resolved = true
474
+ resolve([...allHosts])
475
+ }
476
+ })
438
477
  }
439
- }
440
-
441
- return [...hosts]
478
+ })
442
479
  }
443
480
 
444
481
  /** Evict an arbitrary “oldest” entry from a Map (iteration order). */
@@ -133,6 +133,11 @@ export default class TopicBroadcaster implements Broadcaster {
133
133
  private readonly requireAcknowledgmentFromSpecificHostsForTopics: Record<string, 'all' | 'any' | string[]>
134
134
  private readonly networkPreset: 'mainnet' | 'testnet' | 'local'
135
135
 
136
+ // Cache for findInterestedHosts to avoid repeated SHIP tracker lookups
137
+ private interestedHostsCache: { hosts: Record<string, Set<string>>, expiresAt: number } | null = null
138
+ private interestedHostsInFlight: Promise<Record<string, Set<string>>> | null = null
139
+ private readonly interestedHostsTtlMs: number
140
+
136
141
  /**
137
142
  * Constructs an instance of the SHIP broadcaster.
138
143
  *
@@ -156,6 +161,7 @@ export default class TopicBroadcaster implements Broadcaster {
156
161
  config.requireAcknowledgmentFromAnyHostForTopics ?? 'all'
157
162
  this.requireAcknowledgmentFromSpecificHostsForTopics =
158
163
  config.requireAcknowledgmentFromSpecificHostsForTopics ?? {}
164
+ this.interestedHostsTtlMs = 5 * 60 * 1000 // 5 minutes
159
165
  }
160
166
 
161
167
  /**
@@ -461,10 +467,33 @@ export default class TopicBroadcaster implements Broadcaster {
461
467
  }
462
468
  return { 'http://localhost:8080': resultSet }
463
469
  }
464
- // TODO: cache the list of interested hosts to avoid spamming SHIP trackers.
465
- // TODO: Monetize the operation of the SHIP tracker system.
466
- // TODO: Cache ship/slap lookup with expiry (every 5min)
467
470
 
471
+ // Return cached result if still valid
472
+ const now = Date.now()
473
+ if (this.interestedHostsCache != null && this.interestedHostsCache.expiresAt > now) {
474
+ return this.interestedHostsCache.hosts
475
+ }
476
+
477
+ // Deduplicate concurrent requests
478
+ if (this.interestedHostsInFlight != null) {
479
+ return await this.interestedHostsInFlight
480
+ }
481
+
482
+ this.interestedHostsInFlight = this.fetchInterestedHosts()
483
+ try {
484
+ const hosts = await this.interestedHostsInFlight
485
+ this.interestedHostsCache = { hosts, expiresAt: Date.now() + this.interestedHostsTtlMs }
486
+ return hosts
487
+ } finally {
488
+ this.interestedHostsInFlight = null
489
+ }
490
+ }
491
+
492
+ /**
493
+ * Performs the actual SHIP lookup to discover interested hosts.
494
+ * @private
495
+ */
496
+ private async fetchInterestedHosts (): Promise<Record<string, Set<string>>> {
468
497
  // Find all SHIP advertisements for the topics we care about
469
498
  const results: Record<string, Set<string>> = {}
470
499
  const answer = await this.resolver.query(
@@ -488,7 +517,6 @@ export default class TopicBroadcaster implements Broadcaster {
488
517
  !this.topics.includes(parsed.topicOrService) ||
489
518
  parsed.protocol !== 'SHIP'
490
519
  ) {
491
- // This should make us think a LOT less highly of this SHIP tracker if it ever happens...
492
520
  continue
493
521
  }
494
522
  if (results[parsed.domain] === undefined) {
@@ -367,7 +367,7 @@ describe('LookupResolver', () => {
367
367
  ])
368
368
  })
369
369
 
370
- it('should handle multiple SLAP trackers and aggregate results from multiple hosts', async () => {
370
+ it('should handle multiple SLAP trackers and resolve with first responder hosts', async () => {
371
371
  const slapHostKey1 = new PrivateKey(42)
372
372
  const slapWallet1 = new CompletedProtoWallet(slapHostKey1)
373
373
  const slapLib1 = new OverlayAdminTokenTemplate(slapWallet1)
@@ -408,7 +408,7 @@ describe('LookupResolver', () => {
408
408
  0
409
409
  )
410
410
 
411
- // SLAP trackers return hosts
411
+ // SLAP trackers return hosts — first responder wins
412
412
  mockFacilitator.lookup
413
413
  .mockReturnValueOnce({
414
414
  type: 'output-list',
@@ -429,7 +429,7 @@ describe('LookupResolver', () => {
429
429
  ]
430
430
  })
431
431
 
432
- // Hosts respond to the query
432
+ // Only the first-resolved tracker's host gets queried
433
433
  mockFacilitator.lookup
434
434
  .mockReturnValueOnce({
435
435
  type: 'output-list',
@@ -440,15 +440,6 @@ describe('LookupResolver', () => {
440
440
  }
441
441
  ]
442
442
  })
443
- .mockReturnValueOnce({
444
- type: 'output-list',
445
- outputs: [
446
- {
447
- beef: sampleBeef4,
448
- outputIndex: 1
449
- }
450
- ]
451
- })
452
443
 
453
444
  const r = new LookupResolver({
454
445
  facilitator: mockFacilitator,
@@ -460,55 +451,45 @@ describe('LookupResolver', () => {
460
451
  query: { test: 1 }
461
452
  })
462
453
 
454
+ // Only the first tracker's host results are returned
463
455
  expect(res).toEqual({
464
456
  type: 'output-list',
465
457
  outputs: [
466
- { beef: sampleBeef3, outputIndex: 0 },
467
- { beef: sampleBeef4, outputIndex: 1 }
458
+ { beef: sampleBeef3, outputIndex: 0 }
468
459
  ]
469
460
  })
470
461
 
471
- expect(mockFacilitator.lookup.mock.calls).toEqual([
472
- [
473
- 'https://mock.slap1',
474
- {
475
- service: 'ls_slap',
476
- query: {
477
- service: 'ls_foo'
478
- }
479
- },
480
- 5000
481
- ],
482
- [
483
- 'https://mock.slap2',
484
- {
485
- service: 'ls_slap',
486
- query: {
487
- service: 'ls_foo'
488
- }
489
- },
490
- 5000
491
- ],
492
- [
493
- 'https://slaphost1.com',
494
- {
495
- service: 'ls_foo',
496
- query: {
497
- test: 1
498
- }
499
- },
500
- undefined
501
- ],
502
- [
503
- 'https://slaphost2.com',
504
- {
505
- service: 'ls_foo',
506
- query: {
507
- test: 1
508
- }
509
- },
510
- undefined
511
- ]
462
+ // Both SLAP trackers are queried, but only the first host is used for the actual query
463
+ expect(mockFacilitator.lookup.mock.calls.length).toBeGreaterThanOrEqual(3)
464
+ expect(mockFacilitator.lookup.mock.calls[0]).toEqual([
465
+ 'https://mock.slap1',
466
+ {
467
+ service: 'ls_slap',
468
+ query: {
469
+ service: 'ls_foo'
470
+ }
471
+ },
472
+ 5000
473
+ ])
474
+ expect(mockFacilitator.lookup.mock.calls[1]).toEqual([
475
+ 'https://mock.slap2',
476
+ {
477
+ service: 'ls_slap',
478
+ query: {
479
+ service: 'ls_foo'
480
+ }
481
+ },
482
+ 5000
483
+ ])
484
+ expect(mockFacilitator.lookup.mock.calls[2]).toEqual([
485
+ 'https://slaphost1.com',
486
+ {
487
+ service: 'ls_foo',
488
+ query: {
489
+ test: 1
490
+ }
491
+ },
492
+ undefined
512
493
  ])
513
494
  })
514
495