@bsv/sdk 2.0.1 → 2.0.3

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 (44) hide show
  1. package/dist/cjs/package.json +1 -1
  2. package/dist/cjs/src/identity/ContactsManager.js +26 -14
  3. package/dist/cjs/src/identity/ContactsManager.js.map +1 -1
  4. package/dist/cjs/src/identity/IdentityClient.js +12 -8
  5. package/dist/cjs/src/identity/IdentityClient.js.map +1 -1
  6. package/dist/cjs/src/overlay-tools/LookupResolver.js +85 -41
  7. package/dist/cjs/src/overlay-tools/LookupResolver.js.map +1 -1
  8. package/dist/cjs/src/overlay-tools/SHIPBroadcaster.js +28 -4
  9. package/dist/cjs/src/overlay-tools/SHIPBroadcaster.js.map +1 -1
  10. package/dist/cjs/src/wallet/WalletClient.js +3 -3
  11. package/dist/cjs/src/wallet/WalletClient.js.map +1 -1
  12. package/dist/cjs/src/wallet/substrates/WalletWireTransceiver.js.map +1 -1
  13. package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
  14. package/dist/esm/src/identity/ContactsManager.js +26 -14
  15. package/dist/esm/src/identity/ContactsManager.js.map +1 -1
  16. package/dist/esm/src/identity/IdentityClient.js +12 -8
  17. package/dist/esm/src/identity/IdentityClient.js.map +1 -1
  18. package/dist/esm/src/overlay-tools/LookupResolver.js +85 -41
  19. package/dist/esm/src/overlay-tools/LookupResolver.js.map +1 -1
  20. package/dist/esm/src/overlay-tools/SHIPBroadcaster.js +29 -4
  21. package/dist/esm/src/overlay-tools/SHIPBroadcaster.js.map +1 -1
  22. package/dist/esm/src/wallet/WalletClient.js +3 -3
  23. package/dist/esm/src/wallet/WalletClient.js.map +1 -1
  24. package/dist/esm/src/wallet/substrates/WalletWireTransceiver.js.map +1 -1
  25. package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
  26. package/dist/types/src/identity/ContactsManager.d.ts.map +1 -1
  27. package/dist/types/src/identity/IdentityClient.d.ts.map +1 -1
  28. package/dist/types/src/overlay-tools/LookupResolver.d.ts +6 -0
  29. package/dist/types/src/overlay-tools/LookupResolver.d.ts.map +1 -1
  30. package/dist/types/src/overlay-tools/SHIPBroadcaster.d.ts +8 -0
  31. package/dist/types/src/overlay-tools/SHIPBroadcaster.d.ts.map +1 -1
  32. package/dist/types/tsconfig.types.tsbuildinfo +1 -1
  33. package/dist/umd/bundle.js +1 -1
  34. package/dist/umd/bundle.js.map +1 -1
  35. package/package.json +1 -1
  36. package/src/identity/ContactsManager.ts +27 -15
  37. package/src/identity/IdentityClient.ts +13 -11
  38. package/src/identity/__tests/IdentityClient.test.ts +2 -2
  39. package/src/overlay-tools/LookupResolver.ts +83 -46
  40. package/src/overlay-tools/SHIPBroadcaster.ts +32 -4
  41. package/src/overlay-tools/__tests/LookupResolver.test.ts +36 -55
  42. package/src/overlay-tools/__tests/SHIPBroadcaster.test.ts +18 -6
  43. package/src/wallet/WalletClient.ts +3 -3
  44. package/src/wallet/substrates/WalletWireTransceiver.ts +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bsv/sdk",
3
- "version": "2.0.1",
3
+ "version": "2.0.3",
4
4
  "type": "module",
5
5
  "description": "BSV Blockchain Software Development Kit",
6
6
  "main": "dist/cjs/mod.js",
@@ -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
 
@@ -19,7 +19,7 @@ describe('SHIPCast', () => {
19
19
  beforeEach(() => {
20
20
  mockFacilitator.send.mockReset()
21
21
  mockResolver.query.mockReset()
22
- consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
22
+ consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => { })
23
23
  })
24
24
 
25
25
  afterEach(() => {
@@ -190,7 +190,7 @@ describe('SHIPCast', () => {
190
190
  })
191
191
  mockFacilitator.send.mockClear()
192
192
 
193
- // Resolver returns the wrong type of data
193
+ // Resolver returns the wrong type of data — new broadcaster so cache is empty
194
194
  mockResolver.query.mockReturnValueOnce({
195
195
  type: 'invalid',
196
196
  bogus: true,
@@ -198,19 +198,27 @@ describe('SHIPCast', () => {
198
198
  different: 'structure'
199
199
  }
200
200
  })
201
- await expect(async () => await b.broadcast(testTx)).rejects.toThrow(
201
+ const b2 = new SHIPCast(['tm_foo'], {
202
+ facilitator: mockFacilitator,
203
+ resolver: mockResolver as unknown as LookupResolver
204
+ })
205
+ await expect(async () => await b2.broadcast(testTx)).rejects.toThrow(
202
206
  'SHIP answer is not an output list.'
203
207
  )
204
208
  expect(mockFacilitator.send).not.toHaveBeenCalled()
205
209
 
206
- // Resolver returns the wrong output structure
210
+ // Resolver returns the wrong output structure — new broadcaster so cache is empty
207
211
  mockResolver.query.mockReturnValueOnce({
208
212
  type: 'output-list',
209
213
  outputs: {
210
214
  different: 'structure'
211
215
  }
212
216
  })
213
- await expect(async () => await b.broadcast(testTx)).rejects.toThrow(
217
+ const b3 = new SHIPCast(['tm_foo'], {
218
+ facilitator: mockFacilitator,
219
+ resolver: mockResolver as unknown as LookupResolver
220
+ })
221
+ await expect(async () => await b3.broadcast(testTx)).rejects.toThrow(
214
222
  'answer.outputs is not iterable'
215
223
  )
216
224
  expect(mockFacilitator.send).not.toHaveBeenCalled()
@@ -237,7 +245,11 @@ describe('SHIPCast', () => {
237
245
  }
238
246
  ]
239
247
  })
240
- response = await b.broadcast(testTx)
248
+ const b4 = new SHIPCast(['tm_foo'], {
249
+ facilitator: mockFacilitator,
250
+ resolver: mockResolver as unknown as LookupResolver
251
+ })
252
+ response = await b4.broadcast(testTx)
241
253
  expect(response).toEqual({
242
254
  status: 'success',
243
255
  txid: testTx.id('hex'),
@@ -119,13 +119,13 @@ export default class WalletClient implements WalletInterface {
119
119
  }
120
120
  }
121
121
 
122
- // Try fast substrates first
122
+ // Try all substrates concurrently, select first available by priority order
123
123
  const fastAttempts = [
124
124
  attemptSubstrate(() => new WindowCWISubstrate()),
125
+ attemptSubstrate(() => new WalletWireTransceiver(new HTTPWalletWire(this.originator))),
125
126
  attemptSubstrate(() => new HTTPWalletJSON(this.originator, 'https://localhost:2121')),
126
127
  attemptSubstrate(() => new HTTPWalletJSON(this.originator)),
127
- attemptSubstrate(() => new ReactNativeWebView(this.originator)),
128
- attemptSubstrate(() => new WalletWireTransceiver(new HTTPWalletWire(this.originator)))
128
+ attemptSubstrate(() => new ReactNativeWebView(this.originator))
129
129
  ]
130
130
 
131
131
  const fastResults = await Promise.allSettled(fastAttempts)
@@ -1686,7 +1686,7 @@ export default class WalletWireTransceiver implements WalletInterface {
1686
1686
  const fieldKey = Utils.toUTF8(resultReader.read(fieldKeyLength))
1687
1687
  const fieldValueLength = resultReader.readVarIntNum()
1688
1688
  keyringForVerifier[fieldKey] = Utils.toBase64(
1689
- resultReader.read(fieldValueLength)
1689
+ resultReader.read(fieldValueLength)
1690
1690
  )
1691
1691
  }
1692
1692
  }