@bsv/sdk 1.10.1 → 1.10.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bsv/sdk",
3
- "version": "1.10.1",
3
+ "version": "1.10.2",
4
4
  "type": "module",
5
5
  "description": "BSV Blockchain Software Development Kit",
6
6
  "main": "dist/cjs/mod.js",
@@ -13,11 +13,16 @@ import {
13
13
  } from '../wallet/index.js'
14
14
  import { BroadcastFailure, BroadcastResponse, Transaction } from '../transaction/index.js'
15
15
  import Certificate from '../auth/certificates/Certificate.js'
16
+ import { VerifiableCertificate } from '../auth/certificates/VerifiableCertificate.js'
16
17
  import { PushDrop } from '../script/index.js'
17
18
  import { PrivateKey, Utils } from '../primitives/index.js'
19
+ import ProtoWallet from '../wallet/ProtoWallet.js'
18
20
  import { LookupResolver, SHIPBroadcaster, TopicBroadcaster, withDoubleSpendRetry } from '../overlay-tools/index.js'
19
21
  import { ContactsManager, Contact } from './ContactsManager.js'
20
22
 
23
+ // Default SocialCert certifier for fallback resolution when no wallet is available
24
+ const DEFAULT_CERTIFIER = '02cf6cdf466951d8dfc9e7c9367511d0007ed6fba35ed42d425cc412fd6cfd4a17'
25
+
21
26
  /**
22
27
  * IdentityClient lets you discover who others are, and let the world know who you are.
23
28
  */
@@ -30,7 +35,7 @@ export class IdentityClient {
30
35
  private readonly originator?: OriginatorDomainNameStringUnder250Bytes
31
36
  ) {
32
37
  this.originator = originator
33
- this.wallet = wallet ?? new WalletClient()
38
+ this.wallet = wallet ?? new WalletClient('auto', this.originator)
34
39
  this.contactsManager = new ContactsManager(this.wallet, this.originator)
35
40
  }
36
41
 
@@ -140,19 +145,33 @@ export class IdentityClient {
140
145
  ): Promise<DisplayableIdentity[]> {
141
146
  if (overrideWithContacts) {
142
147
  // Override results with personal contacts if available
143
- const contacts = await this.contactsManager.getContacts(args.identityKey)
144
- if (contacts.length > 0) {
145
- return contacts
148
+ try {
149
+ const contacts = await this.contactsManager.getContacts(args.identityKey)
150
+ if (contacts.length > 0) {
151
+ return contacts
152
+ }
153
+ } catch (e) {
154
+ // Contacts require wallet - continue to try discovery
146
155
  }
147
156
  }
148
157
 
149
- const { certificates } = await this.wallet.discoverByIdentityKey(
150
- args,
151
- this.originator
152
- )
153
- return certificates.map((cert) => {
154
- return IdentityClient.parseIdentity(cert)
155
- })
158
+ try {
159
+ const { certificates } = await this.wallet.discoverByIdentityKey(
160
+ args,
161
+ this.originator
162
+ )
163
+ return certificates.map((cert) => {
164
+ return IdentityClient.parseIdentity(cert)
165
+ })
166
+ } catch (error: unknown) {
167
+ // Fallback to LookupResolver if no wallet is available
168
+ const errorMessage = error instanceof Error ? error.message : ''
169
+ if (errorMessage.includes('No wallet available')) {
170
+ const certificates = await this.resolveViaLookup({ identityKey: args.identityKey })
171
+ return certificates.map((cert) => IdentityClient.parseIdentity(cert))
172
+ }
173
+ throw error
174
+ }
156
175
  }
157
176
 
158
177
  /**
@@ -167,26 +186,131 @@ export class IdentityClient {
167
186
  overrideWithContacts = true
168
187
  ): Promise<DisplayableIdentity[]> {
169
188
  // Run both queries in parallel for better performance
170
- const [contacts, certificatesResult] = await Promise.all([
171
- overrideWithContacts
172
- ? this.contactsManager.getContacts()
173
- : Promise.resolve([]),
174
- this.wallet.discoverByAttributes(args, this.originator)
175
- ])
176
-
177
- // Fast lookup by identityKey
178
- const contactByKey = new Map<PubKeyHex, Contact>(
179
- contacts.map((contact) => [contact.identityKey, contact] as const)
180
- )
189
+ const contactsPromise = overrideWithContacts
190
+ ? this.contactsManager.getContacts().catch(() => [] as Contact[])
191
+ : Promise.resolve([] as Contact[])
181
192
 
182
- // Guard if certificates might be absent
183
- const certs = certificatesResult?.certificates ?? []
193
+ try {
194
+ const [contacts, certificatesResult] = await Promise.all([
195
+ contactsPromise,
196
+ this.wallet.discoverByAttributes(args, this.originator)
197
+ ])
198
+
199
+ // Fast lookup by identityKey
200
+ const contactByKey = new Map<PubKeyHex, Contact>(
201
+ contacts.map((contact) => [contact.identityKey, contact] as const)
202
+ )
184
203
 
185
- // Parse certificates and substitute with contacts where available
186
- return certs.map(
187
- (cert) =>
188
- contactByKey.get(cert.subject) ?? IdentityClient.parseIdentity(cert)
189
- )
204
+ // Guard if certificates might be absent
205
+ const certs = certificatesResult?.certificates ?? []
206
+
207
+ // Parse certificates and substitute with contacts where available
208
+ return certs.map(
209
+ (cert) =>
210
+ contactByKey.get(cert.subject) ?? IdentityClient.parseIdentity(cert)
211
+ )
212
+ } catch (error: unknown) {
213
+ // Fallback to LookupResolver if no wallet is available
214
+ const errorMessage = error instanceof Error ? error.message : ''
215
+ if (errorMessage.includes('No wallet available')) {
216
+ const certificates = await this.resolveViaLookup({ attributes: args.attributes })
217
+ return certificates.map((cert) => IdentityClient.parseIdentity(cert))
218
+ }
219
+ throw error
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Fallback method to resolve identity certificates via LookupResolver when no wallet is available.
225
+ * Uses the ls_identity overlay with a default SocialCert certifier.
226
+ *
227
+ * @param query - Query parameters: either identityKey or attributes
228
+ * @returns {Promise<IdentityCertificate[]>} Array of identity certificates
229
+ */
230
+ private async resolveViaLookup (
231
+ query: { identityKey?: PubKeyHex, attributes?: Record<string, string> }
232
+ ): Promise<IdentityCertificate[]> {
233
+ const resolver = new LookupResolver({ networkPreset: 'mainnet' })
234
+
235
+ // Build the lookup query based on what was provided
236
+ const lookupQuery: Record<string, any> = {
237
+ certifiers: [DEFAULT_CERTIFIER]
238
+ }
239
+
240
+ if (query.identityKey !== undefined) {
241
+ lookupQuery.identityKey = query.identityKey
242
+ } else if (query.attributes !== undefined) {
243
+ // For attribute search, use the 'any' matcher for flexible matching
244
+ const attributeValues = Object.values(query.attributes)
245
+ if (attributeValues.length === 1) {
246
+ lookupQuery.attributes = { any: attributeValues[0] }
247
+ } else {
248
+ lookupQuery.attributes = query.attributes
249
+ }
250
+ }
251
+
252
+ const lookupResult = await resolver.query({
253
+ service: 'ls_identity',
254
+ query: lookupQuery
255
+ })
256
+
257
+ if (lookupResult.type !== 'output-list' || lookupResult.outputs.length === 0) {
258
+ return []
259
+ }
260
+
261
+ const certificates: IdentityCertificate[] = []
262
+ const anyoneWallet = new ProtoWallet('anyone')
263
+
264
+ for (const output of lookupResult.outputs) {
265
+ try {
266
+ const tx = Transaction.fromBEEF(output.beef)
267
+ const decodedOutput = PushDrop.decode(tx.outputs[output.outputIndex].lockingScript)
268
+ const certData = JSON.parse(Utils.toUTF8(decodedOutput.fields[0]))
269
+
270
+ const verifiableCert = new VerifiableCertificate(
271
+ certData.type,
272
+ certData.serialNumber,
273
+ certData.subject,
274
+ certData.certifier,
275
+ certData.revocationOutpoint,
276
+ certData.fields,
277
+ certData.keyring,
278
+ certData.signature
279
+ )
280
+
281
+ // Decrypt fields using 'anyone' wallet for publicly revealed certificates
282
+ const decryptedFields = await verifiableCert.decryptFields(anyoneWallet)
283
+
284
+ // Verify the certificate is valid
285
+ await verifiableCert.verify()
286
+
287
+ // Construct IdentityCertificate with default certifier info
288
+ const identityCert: IdentityCertificate = {
289
+ type: certData.type,
290
+ serialNumber: certData.serialNumber,
291
+ subject: certData.subject,
292
+ certifier: certData.certifier,
293
+ revocationOutpoint: certData.revocationOutpoint,
294
+ fields: certData.fields,
295
+ signature: certData.signature,
296
+ certifierInfo: {
297
+ name: 'SocialCert',
298
+ iconUrl: 'https://socialcert.net/favicon.ico',
299
+ description: 'Social identity verification',
300
+ trust: 5
301
+ },
302
+ publiclyRevealedKeyring: certData.keyring,
303
+ decryptedFields
304
+ }
305
+
306
+ certificates.push(identityCert)
307
+ } catch (e) {
308
+ // Skip invalid certificates and continue processing
309
+ continue
310
+ }
311
+ }
312
+
313
+ return certificates
190
314
  }
191
315
 
192
316
  /**
@@ -15,14 +15,41 @@ jest.mock('../../script', () => {
15
15
  }
16
16
  })
17
17
 
18
+ // Store mock functions for LookupResolver so tests can configure them
19
+ const mockLookupResolverQuery = jest.fn()
20
+
18
21
  jest.mock('../../overlay-tools/index.js', () => {
19
22
  return {
20
23
  TopicBroadcaster: jest.fn().mockImplementation(() => ({
21
24
  broadcast: jest.fn().mockResolvedValue('broadcastResult')
25
+ })),
26
+ LookupResolver: jest.fn().mockImplementation(() => ({
27
+ query: mockLookupResolverQuery
28
+ }))
29
+ }
30
+ })
31
+
32
+ // Mock VerifiableCertificate for resolveViaLookup tests
33
+ const mockDecryptFields = jest.fn()
34
+ const mockVerify = jest.fn()
35
+
36
+ jest.mock('../../auth/certificates/VerifiableCertificate.js', () => {
37
+ return {
38
+ VerifiableCertificate: jest.fn().mockImplementation(() => ({
39
+ decryptFields: mockDecryptFields,
40
+ verify: mockVerify
22
41
  }))
23
42
  }
24
43
  })
25
44
 
45
+ // Mock ProtoWallet for resolveViaLookup tests
46
+ jest.mock('../../wallet/ProtoWallet.js', () => {
47
+ return {
48
+ __esModule: true,
49
+ default: jest.fn().mockImplementation(() => ({}))
50
+ }
51
+ })
52
+
26
53
  jest.mock('../../transaction/index.js', () => {
27
54
  return {
28
55
  Transaction: {
@@ -267,6 +294,187 @@ describe('IdentityClient', () => {
267
294
  // Wallet method should not be called when contact is found
268
295
  expect(walletMock.discoverByIdentityKey).not.toHaveBeenCalled()
269
296
  })
297
+
298
+ it('should fallback to LookupResolver when no wallet is available', async () => {
299
+ // Mock wallet methods to throw "No wallet available" error
300
+ walletMock.discoverByIdentityKey = jest.fn().mockRejectedValue(
301
+ new Error('No wallet available over any communication substrate. Install a BSV wallet today!')
302
+ )
303
+
304
+ // Mock ContactsManager to also throw (since it requires wallet)
305
+ const mockContactsManager = identityClient['contactsManager']
306
+ mockContactsManager.getContacts = jest.fn().mockRejectedValue(
307
+ new Error('No wallet available')
308
+ )
309
+
310
+ // Setup mock certificate data that would come from lookup
311
+ const certData = {
312
+ type: KNOWN_IDENTITY_TYPES.xCert,
313
+ serialNumber: 'serial123',
314
+ subject: 'test-identity-key',
315
+ certifier: '02cf6cdf466951d8dfc9e7c9367511d0007ed6fba35ed42d425cc412fd6cfd4a17',
316
+ revocationOutpoint: 'outpoint',
317
+ fields: { userName: 'encrypted', profilePhoto: 'encrypted' },
318
+ keyring: { fieldKey: 'keyringValue' },
319
+ signature: 'sig123'
320
+ }
321
+
322
+ // Mock LookupResolver to return output-list with certificate data
323
+ mockLookupResolverQuery.mockResolvedValue({
324
+ type: 'output-list',
325
+ outputs: [{
326
+ beef: new Uint8Array([1, 2, 3]),
327
+ outputIndex: 0
328
+ }]
329
+ })
330
+
331
+ // Mock PushDrop.decode to return certificate JSON
332
+ const { PushDrop } = require('../../script')
333
+ PushDrop.decode.mockReturnValue({
334
+ fields: [new TextEncoder().encode(JSON.stringify(certData))]
335
+ })
336
+
337
+ // Mock VerifiableCertificate methods
338
+ mockDecryptFields.mockResolvedValue({
339
+ userName: 'TestUser',
340
+ profilePhoto: 'test-photo-url'
341
+ })
342
+ mockVerify.mockResolvedValue(true)
343
+
344
+ const identities = await identityClient.resolveByIdentityKey(
345
+ { identityKey: 'test-identity-key' },
346
+ false // Don't override with contacts
347
+ )
348
+
349
+ // Verify wallet was called and threw
350
+ expect(walletMock.discoverByIdentityKey).toHaveBeenCalled()
351
+
352
+ // Verify LookupResolver was queried
353
+ expect(mockLookupResolverQuery).toHaveBeenCalledWith({
354
+ service: 'ls_identity',
355
+ query: {
356
+ certifiers: ['02cf6cdf466951d8dfc9e7c9367511d0007ed6fba35ed42d425cc412fd6cfd4a17'],
357
+ identityKey: 'test-identity-key'
358
+ }
359
+ })
360
+
361
+ // Verify results were returned from fallback
362
+ expect(identities).toHaveLength(1)
363
+ expect(identities[0].name).toBe('TestUser')
364
+ expect(identities[0].identityKey).toBe('test-identity-key')
365
+ })
366
+
367
+ it('should return empty array when LookupResolver returns no results', async () => {
368
+ walletMock.discoverByIdentityKey = jest.fn().mockRejectedValue(
369
+ new Error('No wallet available')
370
+ )
371
+
372
+ const mockContactsManager = identityClient['contactsManager']
373
+ mockContactsManager.getContacts = jest.fn().mockRejectedValue(
374
+ new Error('No wallet available')
375
+ )
376
+
377
+ // Mock LookupResolver to return empty results
378
+ mockLookupResolverQuery.mockResolvedValue({
379
+ type: 'output-list',
380
+ outputs: []
381
+ })
382
+
383
+ const identities = await identityClient.resolveByIdentityKey(
384
+ { identityKey: 'nonexistent-key' },
385
+ false
386
+ )
387
+
388
+ expect(identities).toHaveLength(0)
389
+ })
390
+
391
+ it('should return empty array when LookupResolver returns non-output-list type', async () => {
392
+ walletMock.discoverByIdentityKey = jest.fn().mockRejectedValue(
393
+ new Error('No wallet available')
394
+ )
395
+
396
+ const mockContactsManager = identityClient['contactsManager']
397
+ mockContactsManager.getContacts = jest.fn().mockRejectedValue(
398
+ new Error('No wallet available')
399
+ )
400
+
401
+ // Mock LookupResolver to return different type
402
+ mockLookupResolverQuery.mockResolvedValue({
403
+ type: 'freeform',
404
+ result: {}
405
+ })
406
+
407
+ const identities = await identityClient.resolveByIdentityKey(
408
+ { identityKey: 'some-key' },
409
+ false
410
+ )
411
+
412
+ expect(identities).toHaveLength(0)
413
+ })
414
+
415
+ it('should skip invalid certificates and continue processing', async () => {
416
+ walletMock.discoverByIdentityKey = jest.fn().mockRejectedValue(
417
+ new Error('No wallet available')
418
+ )
419
+
420
+ const mockContactsManager = identityClient['contactsManager']
421
+ mockContactsManager.getContacts = jest.fn().mockRejectedValue(
422
+ new Error('No wallet available')
423
+ )
424
+
425
+ // Mock LookupResolver to return multiple outputs
426
+ mockLookupResolverQuery.mockResolvedValue({
427
+ type: 'output-list',
428
+ outputs: [
429
+ { beef: new Uint8Array([1]), outputIndex: 0 },
430
+ { beef: new Uint8Array([2]), outputIndex: 0 }
431
+ ]
432
+ })
433
+
434
+ // First call throws (invalid cert), second succeeds
435
+ const { PushDrop } = require('../../script')
436
+ PushDrop.decode
437
+ .mockImplementationOnce(() => { throw new Error('Invalid script') })
438
+ .mockImplementationOnce(() => ({
439
+ fields: [new TextEncoder().encode(JSON.stringify({
440
+ type: KNOWN_IDENTITY_TYPES.xCert,
441
+ serialNumber: 'serial',
442
+ subject: 'valid-key',
443
+ certifier: 'certifier',
444
+ revocationOutpoint: 'outpoint',
445
+ fields: {},
446
+ keyring: {},
447
+ signature: 'sig'
448
+ }))]
449
+ }))
450
+
451
+ mockDecryptFields.mockResolvedValue({ userName: 'ValidUser' })
452
+ mockVerify.mockResolvedValue(true)
453
+
454
+ const identities = await identityClient.resolveByIdentityKey(
455
+ { identityKey: 'some-key' },
456
+ false
457
+ )
458
+
459
+ // Should have 1 identity (the valid one)
460
+ expect(identities).toHaveLength(1)
461
+ expect(identities[0].name).toBe('ValidUser')
462
+ })
463
+
464
+ it('should re-throw non-wallet errors', async () => {
465
+ walletMock.discoverByIdentityKey = jest.fn().mockRejectedValue(
466
+ new Error('Network timeout')
467
+ )
468
+
469
+ const mockContactsManager = identityClient['contactsManager']
470
+ mockContactsManager.getContacts = jest.fn().mockRejectedValue(
471
+ new Error('No wallet available')
472
+ )
473
+
474
+ await expect(
475
+ identityClient.resolveByIdentityKey({ identityKey: 'test-key' }, false)
476
+ ).rejects.toThrow('Network timeout')
477
+ })
270
478
  })
271
479
 
272
480
  it('should throw if createAction returns no tx', async () => {
@@ -367,7 +575,11 @@ describe('IdentityClient', () => {
367
575
  {
368
576
  name: 'Alice Smith',
369
577
  identityKey: 'alice-key',
370
- avatarURL: '', abbreviatedKey: 'alice-i...', badgeIconURL: '', badgeLabel: '', badgeClickURL: ''
578
+ avatarURL: '',
579
+ abbreviatedKey: 'alice-i...',
580
+ badgeIconURL: '',
581
+ badgeLabel: '',
582
+ badgeClickURL: ''
371
583
  }
372
584
  ]
373
585
 
@@ -416,6 +628,82 @@ describe('IdentityClient', () => {
416
628
  expect(identities[0].name).toBe('alice@example.com') // Should be discovered identity, not contact
417
629
  expect(mockContactsManager.getContacts).not.toHaveBeenCalled() // Should not fetch contacts
418
630
  })
631
+
632
+ it('should fallback to LookupResolver when no wallet is available for attribute search', async () => {
633
+ walletMock.discoverByAttributes = jest.fn().mockRejectedValue(
634
+ new Error('No wallet available')
635
+ )
636
+
637
+ // Setup mock certificate data
638
+ const certData = {
639
+ type: KNOWN_IDENTITY_TYPES.emailCert,
640
+ serialNumber: 'serial123',
641
+ subject: 'found-identity-key',
642
+ certifier: '02cf6cdf466951d8dfc9e7c9367511d0007ed6fba35ed42d425cc412fd6cfd4a17',
643
+ revocationOutpoint: 'outpoint',
644
+ fields: { email: 'encrypted' },
645
+ keyring: { fieldKey: 'keyringValue' },
646
+ signature: 'sig123'
647
+ }
648
+
649
+ mockLookupResolverQuery.mockResolvedValue({
650
+ type: 'output-list',
651
+ outputs: [{
652
+ beef: new Uint8Array([1, 2, 3]),
653
+ outputIndex: 0
654
+ }]
655
+ })
656
+
657
+ const { PushDrop } = require('../../script')
658
+ PushDrop.decode.mockReturnValue({
659
+ fields: [new TextEncoder().encode(JSON.stringify(certData))]
660
+ })
661
+
662
+ mockDecryptFields.mockResolvedValue({ email: 'found@example.com' })
663
+ mockVerify.mockResolvedValue(true)
664
+
665
+ const identities = await identityClient.resolveByAttributes(
666
+ { attributes: { email: 'found@example.com' } },
667
+ false
668
+ )
669
+
670
+ // Verify LookupResolver was queried with attributes
671
+ expect(mockLookupResolverQuery).toHaveBeenCalledWith({
672
+ service: 'ls_identity',
673
+ query: {
674
+ certifiers: ['02cf6cdf466951d8dfc9e7c9367511d0007ed6fba35ed42d425cc412fd6cfd4a17'],
675
+ attributes: { any: 'found@example.com' }
676
+ }
677
+ })
678
+
679
+ expect(identities).toHaveLength(1)
680
+ expect(identities[0].name).toBe('found@example.com')
681
+ })
682
+
683
+ it('should use multi-attribute query when multiple attributes provided', async () => {
684
+ walletMock.discoverByAttributes = jest.fn().mockRejectedValue(
685
+ new Error('No wallet available')
686
+ )
687
+
688
+ mockLookupResolverQuery.mockResolvedValue({
689
+ type: 'output-list',
690
+ outputs: []
691
+ })
692
+
693
+ await identityClient.resolveByAttributes(
694
+ { attributes: { firstName: 'John', lastName: 'Doe' } },
695
+ false
696
+ )
697
+
698
+ // Verify query uses full attributes object when more than one
699
+ expect(mockLookupResolverQuery).toHaveBeenCalledWith({
700
+ service: 'ls_identity',
701
+ query: {
702
+ certifiers: ['02cf6cdf466951d8dfc9e7c9367511d0007ed6fba35ed42d425cc412fd6cfd4a17'],
703
+ attributes: { firstName: 'John', lastName: 'Doe' }
704
+ }
705
+ })
706
+ })
419
707
  })
420
708
 
421
709
  describe('parseIdentity', () => {