@bsv/sdk 2.1.1 → 2.1.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 (79) hide show
  1. package/dist/cjs/package.json +1 -1
  2. package/dist/cjs/src/auth/Peer.js +21 -18
  3. package/dist/cjs/src/auth/Peer.js.map +1 -1
  4. package/dist/cjs/src/auth/SessionManager.js.map +1 -1
  5. package/dist/cjs/src/auth/clients/AuthFetch.js +4 -1
  6. package/dist/cjs/src/auth/clients/AuthFetch.js.map +1 -1
  7. package/dist/cjs/src/identity/ContactsManager.js +44 -6
  8. package/dist/cjs/src/identity/ContactsManager.js.map +1 -1
  9. package/dist/cjs/src/identity/IdentityClient.js +106 -37
  10. package/dist/cjs/src/identity/IdentityClient.js.map +1 -1
  11. package/dist/cjs/src/overlay-tools/LookupResolver.js +180 -82
  12. package/dist/cjs/src/overlay-tools/LookupResolver.js.map +1 -1
  13. package/dist/cjs/src/primitives/Hash.js +173 -50
  14. package/dist/cjs/src/primitives/Hash.js.map +1 -1
  15. package/dist/cjs/src/primitives/SymmetricKey.js +123 -1
  16. package/dist/cjs/src/primitives/SymmetricKey.js.map +1 -1
  17. package/dist/cjs/src/transaction/MerklePath.js +1 -1
  18. package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
  19. package/dist/esm/src/auth/Peer.js +28 -18
  20. package/dist/esm/src/auth/Peer.js.map +1 -1
  21. package/dist/esm/src/auth/SessionManager.js.map +1 -1
  22. package/dist/esm/src/auth/clients/AuthFetch.js +4 -1
  23. package/dist/esm/src/auth/clients/AuthFetch.js.map +1 -1
  24. package/dist/esm/src/identity/ContactsManager.js +44 -6
  25. package/dist/esm/src/identity/ContactsManager.js.map +1 -1
  26. package/dist/esm/src/identity/IdentityClient.js +106 -37
  27. package/dist/esm/src/identity/IdentityClient.js.map +1 -1
  28. package/dist/esm/src/overlay-tools/LookupResolver.js +180 -82
  29. package/dist/esm/src/overlay-tools/LookupResolver.js.map +1 -1
  30. package/dist/esm/src/primitives/Hash.js +177 -50
  31. package/dist/esm/src/primitives/Hash.js.map +1 -1
  32. package/dist/esm/src/primitives/SymmetricKey.js +123 -1
  33. package/dist/esm/src/primitives/SymmetricKey.js.map +1 -1
  34. package/dist/esm/src/transaction/MerklePath.js +1 -1
  35. package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
  36. package/dist/types/src/auth/Peer.d.ts +3 -3
  37. package/dist/types/src/auth/Peer.d.ts.map +1 -1
  38. package/dist/types/src/auth/SessionManager.d.ts +21 -0
  39. package/dist/types/src/auth/SessionManager.d.ts.map +1 -1
  40. package/dist/types/src/auth/clients/AuthFetch.d.ts +2 -2
  41. package/dist/types/src/auth/clients/AuthFetch.d.ts.map +1 -1
  42. package/dist/types/src/identity/ContactsManager.d.ts +13 -2
  43. package/dist/types/src/identity/ContactsManager.d.ts.map +1 -1
  44. package/dist/types/src/identity/IdentityClient.d.ts +50 -24
  45. package/dist/types/src/identity/IdentityClient.d.ts.map +1 -1
  46. package/dist/types/src/overlay-tools/LookupResolver.d.ts +15 -1
  47. package/dist/types/src/overlay-tools/LookupResolver.d.ts.map +1 -1
  48. package/dist/types/src/primitives/Hash.d.ts +21 -16
  49. package/dist/types/src/primitives/Hash.d.ts.map +1 -1
  50. package/dist/types/src/primitives/SymmetricKey.d.ts.map +1 -1
  51. package/dist/types/src/wallet/Wallet.interfaces.d.ts +16 -1
  52. package/dist/types/src/wallet/Wallet.interfaces.d.ts.map +1 -1
  53. package/dist/types/src/wallet/WalletClient.d.ts +1 -1
  54. package/dist/types/src/wallet/WalletClient.d.ts.map +1 -1
  55. package/dist/types/src/wallet/substrates/window.CWI.d.ts +1 -1
  56. package/dist/types/src/wallet/substrates/window.CWI.d.ts.map +1 -1
  57. package/dist/types/tsconfig.types.tsbuildinfo +1 -1
  58. package/dist/umd/bundle.js +4 -4
  59. package/package.json +1 -1
  60. package/src/auth/Peer.ts +30 -20
  61. package/src/auth/SessionManager.ts +22 -0
  62. package/src/auth/__tests/Peer.test.ts +47 -1
  63. package/src/auth/clients/AuthFetch.ts +6 -3
  64. package/src/identity/ContactsManager.ts +47 -6
  65. package/src/identity/IdentityClient.ts +137 -53
  66. package/src/identity/__tests/IdentityClient.additional.test.ts +150 -1
  67. package/src/identity/__tests/IdentityClient.test.ts +4 -4
  68. package/src/overlay-tools/LookupResolver.ts +191 -77
  69. package/src/overlay-tools/__tests/LookupResolver.additional.test.ts +90 -0
  70. package/src/primitives/Hash.ts +232 -96
  71. package/src/primitives/SymmetricKey.ts +145 -1
  72. package/src/primitives/__tests/Hash.additional.test.ts +65 -0
  73. package/src/primitives/__tests/Hash.test.ts +6 -1
  74. package/src/script/__tests/Spend.test.ts +45 -4
  75. package/src/transaction/MerklePath.ts +1 -1
  76. package/src/transaction/__tests/Transaction.test.ts +17 -0
  77. package/src/wallet/Wallet.interfaces.ts +16 -1
  78. package/src/wallet/WalletClient.ts +1 -1
  79. package/src/wallet/substrates/window.CWI.ts +1 -1
@@ -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