@bsv/sdk 2.1.1 → 2.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/dist/cjs/package.json +1 -1
  2. package/dist/cjs/src/identity/ContactsManager.js +44 -6
  3. package/dist/cjs/src/identity/ContactsManager.js.map +1 -1
  4. package/dist/cjs/src/identity/IdentityClient.js +106 -37
  5. package/dist/cjs/src/identity/IdentityClient.js.map +1 -1
  6. package/dist/cjs/src/overlay-tools/LookupResolver.js +85 -58
  7. package/dist/cjs/src/overlay-tools/LookupResolver.js.map +1 -1
  8. package/dist/cjs/src/primitives/Hash.js +173 -50
  9. package/dist/cjs/src/primitives/Hash.js.map +1 -1
  10. package/dist/cjs/src/primitives/SymmetricKey.js +123 -1
  11. package/dist/cjs/src/primitives/SymmetricKey.js.map +1 -1
  12. package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
  13. package/dist/esm/src/identity/ContactsManager.js +44 -6
  14. package/dist/esm/src/identity/ContactsManager.js.map +1 -1
  15. package/dist/esm/src/identity/IdentityClient.js +106 -37
  16. package/dist/esm/src/identity/IdentityClient.js.map +1 -1
  17. package/dist/esm/src/overlay-tools/LookupResolver.js +85 -58
  18. package/dist/esm/src/overlay-tools/LookupResolver.js.map +1 -1
  19. package/dist/esm/src/primitives/Hash.js +177 -50
  20. package/dist/esm/src/primitives/Hash.js.map +1 -1
  21. package/dist/esm/src/primitives/SymmetricKey.js +123 -1
  22. package/dist/esm/src/primitives/SymmetricKey.js.map +1 -1
  23. package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
  24. package/dist/types/src/identity/ContactsManager.d.ts +13 -2
  25. package/dist/types/src/identity/ContactsManager.d.ts.map +1 -1
  26. package/dist/types/src/identity/IdentityClient.d.ts +50 -24
  27. package/dist/types/src/identity/IdentityClient.d.ts.map +1 -1
  28. package/dist/types/src/overlay-tools/LookupResolver.d.ts +14 -1
  29. package/dist/types/src/overlay-tools/LookupResolver.d.ts.map +1 -1
  30. package/dist/types/src/primitives/Hash.d.ts +21 -16
  31. package/dist/types/src/primitives/Hash.d.ts.map +1 -1
  32. package/dist/types/src/primitives/SymmetricKey.d.ts.map +1 -1
  33. package/dist/types/src/wallet/Wallet.interfaces.d.ts +16 -1
  34. package/dist/types/src/wallet/Wallet.interfaces.d.ts.map +1 -1
  35. package/dist/types/src/wallet/WalletClient.d.ts +1 -1
  36. package/dist/types/src/wallet/WalletClient.d.ts.map +1 -1
  37. package/dist/types/src/wallet/substrates/window.CWI.d.ts +1 -1
  38. package/dist/types/src/wallet/substrates/window.CWI.d.ts.map +1 -1
  39. package/dist/types/tsconfig.types.tsbuildinfo +1 -1
  40. package/dist/umd/bundle.js +3 -3
  41. package/package.json +1 -1
  42. package/src/identity/ContactsManager.ts +47 -6
  43. package/src/identity/IdentityClient.ts +137 -53
  44. package/src/identity/__tests/IdentityClient.additional.test.ts +150 -1
  45. package/src/identity/__tests/IdentityClient.test.ts +4 -4
  46. package/src/overlay-tools/LookupResolver.ts +94 -57
  47. package/src/primitives/Hash.ts +232 -96
  48. package/src/primitives/SymmetricKey.ts +145 -1
  49. package/src/primitives/__tests/Hash.additional.test.ts +65 -0
  50. package/src/primitives/__tests/Hash.test.ts +6 -1
  51. package/src/wallet/Wallet.interfaces.ts +16 -1
  52. package/src/wallet/WalletClient.ts +1 -1
  53. package/src/wallet/substrates/window.CWI.ts +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bsv/sdk",
3
- "version": "2.1.1",
3
+ "version": "2.1.2",
4
4
  "type": "module",
5
5
  "description": "BSV Blockchain Software Development Kit",
6
6
  "main": "dist/cjs/mod.js",
@@ -34,38 +34,74 @@ export class ContactsManager {
34
34
  private readonly CONTACTS_CACHE_KEY = 'metanet-contacts'
35
35
  private readonly originator?: string
36
36
 
37
+ // Performance state — prevents thundering herd of concurrent contact loads and
38
+ // short-circuits the overlay path entirely when we've previously observed an
39
+ // empty contacts basket. Both are invalidated by saveContact/removeContact and
40
+ // by an explicit forceRefresh.
41
+ private inFlightLoad: Promise<Contact[]> | null = null
42
+ private knownEmpty = false
43
+
37
44
  constructor (wallet?: WalletInterface, originator?: string) {
38
45
  this.wallet = wallet ?? new WalletClient()
39
46
  this.originator = originator
40
47
  }
41
48
 
42
49
  /**
43
- * Load all records from the contacts basket
50
+ * Load all records from the contacts basket.
51
+ *
52
+ * Concurrent calls share a single in-flight load (no thundering herd). After
53
+ * the basket has been observed empty once, subsequent calls return `[]`
54
+ * synchronously without hitting the wallet — until `forceRefresh` is passed
55
+ * or a contact is saved/removed.
56
+ *
44
57
  * @param identityKey Optional specific identity key to fetch
45
58
  * @param forceRefresh Whether to force a check for new contact data
46
59
  * @param limit Maximum number of contacts to return
47
- * @returns A promise that resolves with an array of contacts
48
60
  */
49
61
  async getContacts (identityKey?: PubKeyHex, forceRefresh = false, limit = 1000): Promise<Contact[]> {
62
+ if (forceRefresh) this.invalidate()
63
+
64
+ if (this.knownEmpty) return []
65
+
50
66
  if (!forceRefresh) {
51
67
  const fromCache = this.loadCachedContacts(identityKey)
52
68
  if (fromCache !== null) return fromCache
53
69
  }
54
70
 
55
- const tags = await this.buildIdentityKeyTags(identityKey)
71
+ // Coalesce concurrent loads onto a single Promise so a fan-out of N
72
+ // identity calls produces ONE listOutputs + decrypt batch, not N.
73
+ this.inFlightLoad ??= this.loadContactsFromWallet(limit).finally(() => {
74
+ this.inFlightLoad = null
75
+ })
76
+ const all = await this.inFlightLoad
77
+ return identityKey == null ? all : all.filter(c => c.identityKey === identityKey)
78
+ }
79
+
80
+ /** Reset cached state. Call after writes. */
81
+ private invalidate (): void {
82
+ this.cache.removeItem(this.CONTACTS_CACHE_KEY)
83
+ this.knownEmpty = false
84
+ this.inFlightLoad = null
85
+ }
86
+
87
+ /** Underlying wallet load — invoked at most once concurrently via `inFlightLoad`. */
88
+ private async loadContactsFromWallet (limit: number): Promise<Contact[]> {
89
+ // Always load the full basket so subsequent filters (by identityKey) hit cache.
90
+ // Tag filtering is reserved for explicit per-key write paths.
56
91
  const outputs = await this.wallet.listOutputs(
57
- { basket: 'contacts', include: 'locking scripts', includeCustomInstructions: true, tags, limit },
92
+ { basket: 'contacts', include: 'locking scripts', includeCustomInstructions: true, tags: [], limit },
58
93
  this.originator
59
94
  )
60
95
 
61
96
  if (outputs.outputs == null || outputs.outputs.length === 0) {
62
97
  this.cache.setItem(this.CONTACTS_CACHE_KEY, JSON.stringify([]))
98
+ this.knownEmpty = true
63
99
  return []
64
100
  }
65
101
 
66
102
  const contacts = await this.decryptContactOutputs(outputs.outputs)
67
103
  this.cache.setItem(this.CONTACTS_CACHE_KEY, JSON.stringify(contacts))
68
- return identityKey != null ? contacts.filter(c => c.identityKey === identityKey) : contacts
104
+ return contacts
69
105
  }
70
106
 
71
107
  /** Returns cached contacts (optionally filtered) or null if cache is missing/invalid. */
@@ -158,6 +194,8 @@ export class ContactsManager {
158
194
  await this.createContactOutput(lockingScript, keyID, hashedIdentityKey, contact)
159
195
  }
160
196
  this.cache.setItem(this.CONTACTS_CACHE_KEY, JSON.stringify(contacts))
197
+ this.knownEmpty = false
198
+ this.inFlightLoad = null
161
199
  }
162
200
 
163
201
  /** Computes the HMAC-based hash of an identity key for tag indexing. */
@@ -265,11 +303,14 @@ export class ContactsManager {
265
303
  if (cached != null && cached !== '') {
266
304
  try {
267
305
  const contacts: Contact[] = JSON.parse(cached)
268
- this.cache.setItem(this.CONTACTS_CACHE_KEY, JSON.stringify(contacts.filter(c => c.identityKey !== identityKey)))
306
+ const remaining = contacts.filter(c => c.identityKey !== identityKey)
307
+ this.cache.setItem(this.CONTACTS_CACHE_KEY, JSON.stringify(remaining))
308
+ this.knownEmpty = remaining.length === 0
269
309
  } catch (e) {
270
310
  console.warn('Failed to update cache after contact removal:', e)
271
311
  }
272
312
  }
313
+ this.inFlightLoad = null
273
314
 
274
315
  const tags = await this.buildIdentityKeyTags(identityKey)
275
316
  const outputs = await this.wallet.listOutputs(
@@ -18,30 +18,74 @@ import { PrivateKey, Utils } from '../primitives/index.js'
18
18
  import { LookupResolver, SHIPBroadcaster, TopicBroadcaster, withDoubleSpendRetry } from '../overlay-tools/index.js'
19
19
  import { ContactsManager, Contact } from './ContactsManager.js'
20
20
 
21
+ /**
22
+ * Maximum number of identity certificates to parse synchronously before yielding to the
23
+ * event loop. Keeps the main thread responsive when an overlay query returns many results
24
+ * (e.g. a bulk enrichment of N identityKeys).
25
+ */
26
+ const PARSE_BATCH_SIZE = 32
27
+
28
+ /**
29
+ * Yield control to the event loop so queued microtasks / timers can run. Uses
30
+ * `scheduler.yield()` when available (Chromium) or a 0ms macrotask fallback.
31
+ */
32
+ async function yieldToEventLoop (): Promise<void> {
33
+ const sched = (globalThis as any).scheduler
34
+ if (sched != null && typeof sched.yield === 'function') {
35
+ return await sched.yield()
36
+ }
37
+ return await new Promise<void>((resolve) => setTimeout(resolve, 0))
38
+ }
39
+
21
40
  /** Options for {@link IdentityClient.resolveByIdentityKey}. */
22
41
  export interface ResolveByIdentityKeyOptions {
23
- /** Default `true`. If `false`, contacts are skipped and the overlay is queried directly. */
42
+ /**
43
+ * Opt-in to consulting personal contacts before/alongside the overlay. Default `false`.
44
+ *
45
+ * Most callers (including any client without a populated contacts basket) pay no benefit
46
+ * from the contacts path and incur its setup cost. Set `true` only in UI contexts where
47
+ * the user has likely saved contacts and a local cache hit is preferable to a fresh overlay
48
+ * answer.
49
+ */
50
+ useContacts?: boolean
51
+ /**
52
+ * Legacy alias for {@link useContacts}. When provided, takes precedence over the new flag.
53
+ * Kept for binary compatibility — new code should use `useContacts`.
54
+ */
24
55
  overrideWithContacts?: boolean
25
56
  /**
26
- * Default `false`. When `true`, fire contacts and overlay in parallel (legacy behavior) instead
27
- * of short-circuiting on a contacts hit. Use when callers specifically need a fresh overlay
28
- * answer alongside the contact record.
57
+ * When `true` (and {@link useContacts} is also true), fire contacts and overlay in parallel
58
+ * rather than short-circuiting on a contacts hit. Use only when callers specifically need a
59
+ * fresh overlay answer alongside any cached contact record.
29
60
  */
30
61
  parallel?: boolean
31
62
  }
32
63
 
33
64
  /** Options for {@link IdentityClient.resolveByAttributes}. */
34
65
  export interface ResolveByAttributesOptions {
35
- /** Default `true`. If `false`, contacts are skipped and the overlay is queried directly. */
66
+ /**
67
+ * Opt-in to consulting personal contacts before/alongside the overlay. Default `false`.
68
+ * See {@link ResolveByIdentityKeyOptions.useContacts}.
69
+ */
70
+ useContacts?: boolean
71
+ /** Legacy alias for {@link useContacts}. Takes precedence when provided. */
36
72
  overrideWithContacts?: boolean
37
73
  /**
38
- * Default `false`. When `true`, fire contacts and overlay in parallel (legacy behavior). The
39
- * contacts-first path tries to match the supplied attributes against the locally-stored
40
- * contacts; if any contact matches every attribute, the overlay is skipped.
74
+ * When `true` (and {@link useContacts} is also true), fire contacts and overlay in parallel.
41
75
  */
42
76
  parallel?: boolean
43
77
  }
44
78
 
79
+ /** Normalize either legacy boolean / new options object into a canonical { useContacts, parallel }. */
80
+ function normalizeOpts (
81
+ raw: boolean | ResolveByIdentityKeyOptions | ResolveByAttributesOptions | undefined
82
+ ): { useContacts: boolean, parallel: boolean } {
83
+ if (raw === undefined) return { useContacts: false, parallel: false }
84
+ if (typeof raw === 'boolean') return { useContacts: raw, parallel: false }
85
+ const useContacts = raw.overrideWithContacts ?? raw.useContacts ?? false
86
+ return { useContacts, parallel: raw.parallel === true }
87
+ }
88
+
45
89
  /**
46
90
  * IdentityClient lets you discover who others are, and let the world know who you are.
47
91
  */
@@ -153,99 +197,100 @@ export class IdentityClient {
153
197
  }
154
198
 
155
199
  /**
156
- * Resolves displayable identity certificates, issued to a given identity key by a trusted certifier.
200
+ * Resolves displayable identity certificates issued to a given identity key.
201
+ *
202
+ * **Default behavior (changed): contacts are NOT consulted.** Most clients have no
203
+ * contacts saved locally, so the previous "contacts-first" default paid setup cost for no
204
+ * gain. Pass `{ useContacts: true }` to opt in — appropriate when you know the user has
205
+ * saved contacts and prefers a local hit over a fresh overlay answer.
157
206
  *
158
- * By default, contacts are consulted first and the overlay is only queried on a contacts miss.
159
- * Pass `{ parallel: true }` to fire both in parallel (the legacy behavior, useful when callers
160
- * specifically want a fresh overlay answer even when a contact exists).
207
+ * When `useContacts: true`:
208
+ * - Default short-circuits: if a contact matches, the overlay is skipped entirely.
209
+ * - `{ parallel: true }` fires contacts and overlay in parallel; contact wins on hit.
161
210
  *
162
211
  * @param args - Arguments for requesting the discovery based on the identity key.
163
- * @param overrideWithContacts - Whether to consult personal contacts. Default true. When `false`,
164
- * contacts are skipped entirely and the overlay is queried directly. Boolean kept for legacy
165
- * call sites; new code should prefer the options-object form.
166
- * @returns The promise resolves to displayable identities.
212
+ * @param opts - Boolean (legacy) or options object. Boolean `true` `{ useContacts: true }`.
167
213
  */
168
214
  async resolveByIdentityKey (
169
215
  args: DiscoverByIdentityKeyArgs,
170
- overrideWithContacts: boolean | ResolveByIdentityKeyOptions = true
216
+ opts: boolean | ResolveByIdentityKeyOptions = false
171
217
  ): Promise<DisplayableIdentity[]> {
172
- const opts: ResolveByIdentityKeyOptions =
173
- typeof overrideWithContacts === 'boolean'
174
- ? { overrideWithContacts }
175
- : { overrideWithContacts: true, ...overrideWithContacts }
176
- const useContacts = opts.overrideWithContacts !== false
177
- const parallel = opts.parallel === true
178
-
179
- if (useContacts && !parallel) {
218
+ const { useContacts, parallel } = normalizeOpts(opts)
219
+
220
+ // Fast path: skip contacts entirely. Default — straight overlay query,
221
+ // no listOutputs / decrypt / cache churn.
222
+ if (!useContacts) {
223
+ const certificatesResult = await this.wallet.discoverByIdentityKey(args, this.originator)
224
+ const certs = certificatesResult?.certificates ?? []
225
+ return await IdentityClient.parseIdentities(certs)
226
+ }
227
+
228
+ if (!parallel) {
180
229
  const contacts = await this.contactsManager.getContacts(args.identityKey)
181
230
  if (contacts.length > 0) return contacts
182
231
 
183
232
  const certificatesResult = await this.wallet.discoverByIdentityKey(args, this.originator)
184
233
  const certs = certificatesResult?.certificates ?? []
185
- return certs.map((cert) => IdentityClient.parseIdentity(cert))
234
+ return await IdentityClient.parseIdentities(certs)
186
235
  }
187
236
 
188
237
  const [contacts, certificatesResult] = await Promise.all([
189
- useContacts
190
- ? this.contactsManager.getContacts(args.identityKey)
191
- : Promise.resolve([] as Contact[]),
238
+ this.contactsManager.getContacts(args.identityKey),
192
239
  this.wallet.discoverByIdentityKey(args, this.originator)
193
240
  ])
194
241
 
195
242
  if (contacts.length > 0) return contacts
196
243
  const certs = certificatesResult?.certificates ?? []
197
- return certs.map((cert) => IdentityClient.parseIdentity(cert))
244
+ return await IdentityClient.parseIdentities(certs)
198
245
  }
199
246
 
200
247
  /**
201
- * Resolves displayable identity certificates by specific identity attributes, issued by a trusted entity.
248
+ * Resolves displayable identity certificates by specific identity attributes.
202
249
  *
203
- * By default, contacts are consulted first: if any contact matches, the overlay call is skipped
204
- * entirely. Set `parallel: true` to keep the legacy parallel behavior.
250
+ * **Default behavior (changed): contacts are NOT consulted.** See
251
+ * {@link resolveByIdentityKey} for the reasoning. Pass `{ useContacts: true }` to opt in.
205
252
  *
206
253
  * @param args - Attributes and optional parameters used to discover certificates.
207
- * @param overrideWithContacts - Whether to consult personal contacts. Default true. Boolean kept
208
- * for legacy call sites; new code should prefer the options-object form.
209
- * @returns The promise resolves to displayable identities.
254
+ * @param opts - Boolean (legacy) or options object. Boolean `true` `{ useContacts: true }`.
210
255
  */
211
256
  async resolveByAttributes (
212
257
  args: DiscoverByAttributesArgs,
213
- overrideWithContacts: boolean | ResolveByAttributesOptions = true
258
+ opts: boolean | ResolveByAttributesOptions = false
214
259
  ): Promise<DisplayableIdentity[]> {
215
- const opts: ResolveByAttributesOptions =
216
- typeof overrideWithContacts === 'boolean'
217
- ? { overrideWithContacts }
218
- : { overrideWithContacts: true, ...overrideWithContacts }
219
- const useContacts = opts.overrideWithContacts !== false
220
- const parallel = opts.parallel === true
221
-
222
- if (useContacts && !parallel) {
260
+ const { useContacts, parallel } = normalizeOpts(opts)
261
+
262
+ // Fast path: skip contacts entirely.
263
+ if (!useContacts) {
264
+ const certificatesResult = await this.wallet.discoverByAttributes(args, this.originator)
265
+ const certs = certificatesResult?.certificates ?? []
266
+ return await IdentityClient.parseIdentities(certs)
267
+ }
268
+
269
+ if (!parallel) {
223
270
  const contacts = await this.contactsManager.getContacts()
224
271
  const matches = this.matchContactsByAttributes(contacts, args)
225
272
  if (matches.length > 0) return matches
226
273
 
227
274
  const certificatesResult = await this.wallet.discoverByAttributes(args, this.originator)
228
275
  const certs = certificatesResult?.certificates ?? []
276
+ if (contacts.length === 0) return await IdentityClient.parseIdentities(certs)
229
277
  const contactByKey = new Map<PubKeyHex, Contact>(
230
278
  contacts.map((contact) => [contact.identityKey, contact] as const)
231
279
  )
232
- return certs.map(
233
- (cert) => contactByKey.get(cert.subject) ?? IdentityClient.parseIdentity(cert)
234
- )
280
+ return await IdentityClient.parseIdentitiesWithOverrides(certs, contactByKey)
235
281
  }
236
282
 
237
283
  const [contacts, certificatesResult] = await Promise.all([
238
- useContacts ? this.contactsManager.getContacts() : Promise.resolve([] as Contact[]),
284
+ this.contactsManager.getContacts(),
239
285
  this.wallet.discoverByAttributes(args, this.originator)
240
286
  ])
241
287
 
288
+ const certs = certificatesResult?.certificates ?? []
289
+ if (contacts.length === 0) return await IdentityClient.parseIdentities(certs)
242
290
  const contactByKey = new Map<PubKeyHex, Contact>(
243
291
  contacts.map((contact) => [contact.identityKey, contact] as const)
244
292
  )
245
- const certs = certificatesResult?.certificates ?? []
246
- return certs.map(
247
- (cert) => contactByKey.get(cert.subject) ?? IdentityClient.parseIdentity(cert)
248
- )
293
+ return await IdentityClient.parseIdentitiesWithOverrides(certs, contactByKey)
249
294
  }
250
295
 
251
296
  /**
@@ -410,6 +455,45 @@ export class IdentityClient {
410
455
  return await this.contactsManager.removeContact(identityKey)
411
456
  }
412
457
 
458
+ /**
459
+ * Parse an array of certificates into DisplayableIdentity records, yielding to the
460
+ * event loop every {@link PARSE_BATCH_SIZE} entries so large result sets don't hog
461
+ * the main thread. Equivalent to `certs.map(parseIdentity)` for small inputs.
462
+ */
463
+ static async parseIdentities (certs: IdentityCertificate[]): Promise<DisplayableIdentity[]> {
464
+ const n = certs.length
465
+ if (n <= PARSE_BATCH_SIZE) {
466
+ return certs.map((c) => IdentityClient.parseIdentity(c))
467
+ }
468
+ const out: DisplayableIdentity[] = new Array(n)
469
+ for (let i = 0; i < n; i++) {
470
+ out[i] = IdentityClient.parseIdentity(certs[i])
471
+ if ((i + 1) % PARSE_BATCH_SIZE === 0) await yieldToEventLoop()
472
+ }
473
+ return out
474
+ }
475
+
476
+ /**
477
+ * Same as {@link parseIdentities} but consults a contact override map keyed by subject
478
+ * identity key. Used by `resolveByAttributes` when contacts are loaded.
479
+ */
480
+ static async parseIdentitiesWithOverrides (
481
+ certs: IdentityCertificate[],
482
+ contactByKey: Map<PubKeyHex, Contact>
483
+ ): Promise<DisplayableIdentity[]> {
484
+ const n = certs.length
485
+ if (n <= PARSE_BATCH_SIZE) {
486
+ return certs.map((cert) => contactByKey.get(cert.subject) ?? IdentityClient.parseIdentity(cert))
487
+ }
488
+ const out: DisplayableIdentity[] = new Array(n)
489
+ for (let i = 0; i < n; i++) {
490
+ const cert = certs[i]
491
+ out[i] = contactByKey.get(cert.subject) ?? IdentityClient.parseIdentity(cert)
492
+ if ((i + 1) % PARSE_BATCH_SIZE === 0) await yieldToEventLoop()
493
+ }
494
+ return out
495
+ }
496
+
413
497
  /**
414
498
  * Parse out identity and certifier attributes to display from an IdentityCertificate
415
499
  * @param identityToParse - The Identity Certificate to parse
@@ -571,7 +571,7 @@ describe('IdentityClient (additional coverage)', () => {
571
571
  mockContactsManager.getContacts = jest.fn().mockResolvedValue([contact])
572
572
  walletMock.discoverByAttributes = jest.fn().mockResolvedValue({ certificates: [discoveredCertificate] })
573
573
 
574
- const result = await identityClient.resolveByAttributes({ attributes: { email: 'alice@example.com' } })
574
+ const result = await identityClient.resolveByAttributes({ attributes: { email: 'alice@example.com' } }, { useContacts: true })
575
575
  expect(result[0].name).toBe('Alice From Contact')
576
576
  })
577
577
 
@@ -764,4 +764,153 @@ describe('IdentityClient (additional coverage)', () => {
764
764
  expect(mockContactsManager.removeContact).toHaveBeenCalledWith('key-to-remove')
765
765
  })
766
766
  })
767
+
768
+ // ─── useContacts branches in resolveByIdentityKey / resolveByAttributes ─────
769
+
770
+ // Shared helpers — extracted to keep new tests DRY (avoid Sonar duplication gate).
771
+ const xCert = (subject: string, userName: string): any => ({
772
+ type: KNOWN_IDENTITY_TYPES.xCert,
773
+ subject,
774
+ decryptedFields: { userName, profilePhoto: '' },
775
+ certifierInfo: { name: 'CX', iconUrl: '' }
776
+ })
777
+ const emailCertOf = (subject: string, email: string): any => ({
778
+ type: KNOWN_IDENTITY_TYPES.emailCert,
779
+ subject,
780
+ decryptedFields: { email },
781
+ certifierInfo: { name: 'EC', iconUrl: '' }
782
+ })
783
+ const contactOf = (name: string, identityKey: string): any => ({
784
+ name, identityKey, avatarURL: '', abbreviatedKey: '', badgeIconURL: '', badgeLabel: '', badgeClickURL: ''
785
+ })
786
+ const stubDiscoveryByKey = (contacts: any[], certificates: any[]): void => {
787
+ identityClient['contactsManager'].getContacts = jest.fn().mockResolvedValue(contacts)
788
+ walletMock.discoverByIdentityKey = jest.fn().mockResolvedValue({ certificates })
789
+ }
790
+ const stubDiscoveryByAttr = (contacts: any[], certificates: any[]): void => {
791
+ identityClient['contactsManager'].getContacts = jest.fn().mockResolvedValue(contacts)
792
+ walletMock.discoverByAttributes = jest.fn().mockResolvedValue({ certificates })
793
+ }
794
+
795
+ describe('resolveByIdentityKey with useContacts opt-in', () => {
796
+ it('contacts miss falls through to overlay (sequential)', async () => {
797
+ stubDiscoveryByKey([], [xCert('k1', 'XUser')])
798
+ const result = await identityClient.resolveByIdentityKey({ identityKey: 'k1' }, { useContacts: true })
799
+ expect(walletMock.discoverByIdentityKey).toHaveBeenCalled()
800
+ expect(result[0].name).toBe('XUser')
801
+ })
802
+
803
+ it('parallel mode returns contact on hit even though overlay runs', async () => {
804
+ const contact = contactOf('Cached Alice', 'k2')
805
+ stubDiscoveryByKey([contact], [])
806
+ const result = await identityClient.resolveByIdentityKey({ identityKey: 'k2' }, { useContacts: true, parallel: true })
807
+ expect(walletMock.discoverByIdentityKey).toHaveBeenCalled()
808
+ expect(result).toEqual([contact])
809
+ })
810
+
811
+ it('parallel mode contacts miss returns parsed overlay results', async () => {
812
+ stubDiscoveryByKey([], [xCert('k3', 'XOnly')])
813
+ const result = await identityClient.resolveByIdentityKey({ identityKey: 'k3' }, { useContacts: true, parallel: true })
814
+ expect(result[0].name).toBe('XOnly')
815
+ })
816
+
817
+ it('legacy boolean opt-in (true) consults contacts', async () => {
818
+ stubDiscoveryByKey([contactOf('Legacy True', 'k4')], [])
819
+ const result = await identityClient.resolveByIdentityKey({ identityKey: 'k4' }, true)
820
+ expect(result[0].name).toBe('Legacy True')
821
+ expect(walletMock.discoverByIdentityKey).not.toHaveBeenCalled()
822
+ })
823
+
824
+ it('overrideWithContacts legacy alias takes precedence over useContacts', async () => {
825
+ stubDiscoveryByKey([contactOf('Override Wins', 'k5')], [])
826
+ const result = await identityClient.resolveByIdentityKey(
827
+ { identityKey: 'k5' },
828
+ { useContacts: false, overrideWithContacts: true }
829
+ )
830
+ expect(result[0].name).toBe('Override Wins')
831
+ })
832
+ })
833
+
834
+ describe('resolveByAttributes with useContacts opt-in', () => {
835
+ it('contacts no-match falls through to overlay with contact overrides applied', async () => {
836
+ stubDiscoveryByAttr([contactOf('Override Alice', 'k-over')], [emailCertOf('k-over', 'alice@example.com')])
837
+ const result = await identityClient.resolveByAttributes(
838
+ { attributes: { email: 'alice@example.com' } },
839
+ { useContacts: true }
840
+ )
841
+ expect(result[0].name).toBe('Override Alice')
842
+ })
843
+
844
+ it('contacts empty + overlay miss returns empty', async () => {
845
+ stubDiscoveryByAttr([], [])
846
+ const result = await identityClient.resolveByAttributes(
847
+ { attributes: { email: 'nobody@example.com' } },
848
+ { useContacts: true }
849
+ )
850
+ expect(result).toEqual([])
851
+ })
852
+
853
+ it('parallel mode with no contacts parses overlay only', async () => {
854
+ stubDiscoveryByAttr([], [emailCertOf('no-contact-key', 'lone@example.com')])
855
+ const result = await identityClient.resolveByAttributes(
856
+ { attributes: { email: 'lone@example.com' } },
857
+ { useContacts: true, parallel: true }
858
+ )
859
+ expect(result[0].name).toBe('lone@example.com')
860
+ })
861
+
862
+ it('parallel mode with contacts applies overrides on overlay results', async () => {
863
+ stubDiscoveryByAttr([contactOf('Parallel Contact', 'pk')], [emailCertOf('pk', 'p@example.com')])
864
+ const result = await identityClient.resolveByAttributes(
865
+ { attributes: { email: 'p@example.com' } },
866
+ { useContacts: true, parallel: true }
867
+ )
868
+ expect(result[0].name).toBe('Parallel Contact')
869
+ })
870
+
871
+ it('matchContactsByAttributes ignores non-string attribute values', async () => {
872
+ stubDiscoveryByAttr([contactOf('X', 'kkkk')], [])
873
+ const result = await identityClient.resolveByAttributes(
874
+ { attributes: { count: 5 as unknown as string } },
875
+ { useContacts: true }
876
+ )
877
+ // No string-valued attrs → matchContactsByAttributes returns [] → overlay path
878
+ expect(walletMock.discoverByAttributes).toHaveBeenCalled()
879
+ expect(result).toEqual([])
880
+ })
881
+ })
882
+
883
+ describe('parseIdentities batched path', () => {
884
+ it('yields to event loop when batch > PARSE_BATCH_SIZE', async () => {
885
+ const certs = Array.from({ length: 64 }, (_, i) => xCert(`subject-${i}`, `user-${i}`))
886
+ const result = await IdentityClient.parseIdentities(certs)
887
+ expect(result).toHaveLength(64)
888
+ expect(result[63].name).toBe('user-63')
889
+ })
890
+
891
+ it('parseIdentitiesWithOverrides batches with overrides applied', async () => {
892
+ const certs = Array.from({ length: 50 }, (_, i) => xCert(`subject-${i}`, `user-${i}`))
893
+ const overrideMap = new Map<string, any>([
894
+ ['subject-5', contactOf('Override 5', 'subject-5')],
895
+ ['subject-40', contactOf('Override 40', 'subject-40')]
896
+ ])
897
+ const result = await IdentityClient.parseIdentitiesWithOverrides(certs, overrideMap)
898
+ expect(result[5].name).toBe('Override 5')
899
+ expect(result[40].name).toBe('Override 40')
900
+ expect(result[6].name).toBe('user-6')
901
+ })
902
+ })
903
+
904
+ describe('yieldToEventLoop scheduler.yield path', () => {
905
+ const origScheduler = (globalThis as any).scheduler
906
+ afterEach(() => { (globalThis as any).scheduler = origScheduler })
907
+
908
+ it('uses scheduler.yield when available', async () => {
909
+ const yieldFn = jest.fn().mockResolvedValue(undefined)
910
+ ;(globalThis as any).scheduler = { yield: yieldFn }
911
+ const certs = Array.from({ length: 64 }, (_, i) => xCert(`s-${i}`, `u-${i}`))
912
+ await IdentityClient.parseIdentities(certs)
913
+ expect(yieldFn).toHaveBeenCalled()
914
+ })
915
+ })
767
916
  })
@@ -260,11 +260,11 @@ describe('IdentityClient', () => {
260
260
  mockContactsManager.getContacts = jest.fn().mockResolvedValue([contact])
261
261
  walletMock.discoverByIdentityKey = jest.fn().mockResolvedValue({ certificates: [discoveredCertificate] })
262
262
 
263
- const identities = await identityClient.resolveByIdentityKey({ identityKey: 'alice-identity-key' })
263
+ const identities = await identityClient.resolveByIdentityKey({ identityKey: 'alice-identity-key' }, { useContacts: true })
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
- // New default: contacts-first short-circuit the overlay is skipped entirely on a contacts hit.
267
+ // With useContacts opt-in, contacts hit short-circuits the overlay query entirely.
268
268
  expect(walletMock.discoverByIdentityKey).not.toHaveBeenCalled()
269
269
  })
270
270
 
@@ -284,7 +284,7 @@ describe('IdentityClient', () => {
284
284
 
285
285
  const identities = await identityClient.resolveByIdentityKey(
286
286
  { identityKey: 'alice-identity-key' },
287
- { parallel: true }
287
+ { useContacts: true, parallel: true }
288
288
  )
289
289
 
290
290
  expect(identities).toHaveLength(1)
@@ -380,7 +380,7 @@ describe('IdentityClient', () => {
380
380
  mockContactsManager.getContacts = jest.fn().mockResolvedValue([contact])
381
381
  walletMock.discoverByAttributes = jest.fn().mockResolvedValue({ certificates: [discoveredCertificate] })
382
382
 
383
- const identities = await identityClient.resolveByAttributes({ attributes: { name: 'Alice' } })
383
+ const identities = await identityClient.resolveByAttributes({ attributes: { name: 'Alice' } }, { useContacts: true })
384
384
 
385
385
  expect(identities).toHaveLength(1)
386
386
  expect(identities[0].name).toBe('Alice Smith (Personal)') // Contact should be returned, not discovered identity