@bsv/sdk 1.6.24 → 1.6.25

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 (29) hide show
  1. package/dist/cjs/package.json +1 -1
  2. package/dist/cjs/src/identity/ContactsManager.js +298 -0
  3. package/dist/cjs/src/identity/ContactsManager.js.map +1 -0
  4. package/dist/cjs/src/identity/IdentityClient.js +26 -0
  5. package/dist/cjs/src/identity/IdentityClient.js.map +1 -1
  6. package/dist/cjs/src/transaction/Transaction.js +3 -0
  7. package/dist/cjs/src/transaction/Transaction.js.map +1 -1
  8. package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
  9. package/dist/esm/src/identity/ContactsManager.js +295 -0
  10. package/dist/esm/src/identity/ContactsManager.js.map +1 -0
  11. package/dist/esm/src/identity/IdentityClient.js +27 -0
  12. package/dist/esm/src/identity/IdentityClient.js.map +1 -1
  13. package/dist/esm/src/transaction/Transaction.js +3 -0
  14. package/dist/esm/src/transaction/Transaction.js.map +1 -1
  15. package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
  16. package/dist/types/src/identity/ContactsManager.d.ts +28 -0
  17. package/dist/types/src/identity/ContactsManager.d.ts.map +1 -0
  18. package/dist/types/src/identity/IdentityClient.d.ts +21 -1
  19. package/dist/types/src/identity/IdentityClient.d.ts.map +1 -1
  20. package/dist/types/src/transaction/Transaction.d.ts.map +1 -1
  21. package/dist/types/tsconfig.types.tsbuildinfo +1 -1
  22. package/dist/umd/bundle.js +3 -3
  23. package/dist/umd/bundle.js.map +1 -1
  24. package/docs/reference/identity.md +144 -4
  25. package/package.json +1 -1
  26. package/src/identity/ContactsManager.ts +332 -0
  27. package/src/identity/IdentityClient.ts +31 -0
  28. package/src/identity/__tests/IdentityClient.test.ts +326 -2
  29. package/src/transaction/Transaction.ts +2 -0
@@ -29,17 +29,71 @@ jest.mock('../../transaction/index.js', () => {
29
29
  fromAtomicBEEF: jest.fn().mockImplementation((tx) => ({
30
30
  toHexBEEF: () => 'transactionHex'
31
31
  })),
32
- fromBEEF: jest.fn()
32
+ fromBEEF: jest.fn().mockReturnValue({
33
+ outputs: [{ lockingScript: { toHex: () => 'mockLockingScript' } }]
34
+ })
33
35
  }
34
36
  }
35
37
  })
36
38
 
39
+ jest.mock('../../script', () => {
40
+ const mockPushDropInstance = {
41
+ lock: jest.fn().mockResolvedValue({
42
+ toHex: () => 'lockingScriptHex'
43
+ }),
44
+ unlock: jest.fn().mockReturnValue({
45
+ sign: jest.fn().mockResolvedValue({
46
+ toHex: () => 'unlockingScriptHex'
47
+ })
48
+ })
49
+ }
50
+
51
+ const mockPushDrop: any = jest.fn().mockImplementation(() => mockPushDropInstance)
52
+ mockPushDrop.decode = jest.fn().mockReturnValue({
53
+ fields: [new Uint8Array([1, 2, 3, 4])]
54
+ })
55
+
56
+ return {
57
+ PushDrop: mockPushDrop
58
+ }
59
+ })
60
+
61
+ jest.mock('../../primitives/index.js', () => {
62
+ return {
63
+ Utils: {
64
+ toBase64: jest.fn().mockReturnValue('mockKeyID'),
65
+ toArray: jest.fn().mockReturnValue(new Uint8Array()),
66
+ toUTF8: jest.fn().mockImplementation((data) => {
67
+ return new TextDecoder().decode(data)
68
+ }),
69
+ toHex: jest.fn().mockReturnValue('0102030405060708')
70
+ },
71
+ Random: jest.fn().mockReturnValue(new Uint8Array(32)),
72
+ PrivateKey: jest.fn().mockImplementation(() => ({
73
+ toPublicKey: jest.fn().mockReturnValue({
74
+ toString: jest.fn().mockReturnValue('mockPublicKeyString')
75
+ })
76
+ }))
77
+ }
78
+ })
79
+
37
80
  // ----- Begin Test Suite -----
38
81
  describe('IdentityClient', () => {
39
82
  let walletMock: Partial<WalletInterface>
40
83
  let identityClient: IdentityClient
41
84
 
42
85
  beforeEach(() => {
86
+ // Mock localStorage for Node.js environment
87
+ const localStorageMock = {
88
+ getItem: jest.fn(),
89
+ setItem: jest.fn(),
90
+ removeItem: jest.fn()
91
+ }
92
+ Object.defineProperty(global, 'localStorage', {
93
+ value: localStorageMock,
94
+ writable: true
95
+ })
96
+
43
97
  // Create a fake wallet implementing the methods used by IdentityClient.
44
98
  walletMock = {
45
99
  proveCertificate: jest.fn().mockResolvedValue({ keyringForVerifier: 'fakeKeyring' }),
@@ -55,7 +109,12 @@ describe('IdentityClient', () => {
55
109
  signAction: jest.fn().mockResolvedValue({ tx: [4, 5, 6] }),
56
110
  getNetwork: jest.fn().mockResolvedValue({ network: 'testnet' }),
57
111
  discoverByIdentityKey: jest.fn(),
58
- discoverByAttributes: jest.fn()
112
+ discoverByAttributes: jest.fn(),
113
+ // ContactsManager specific methods
114
+ listOutputs: jest.fn().mockResolvedValue({ outputs: [], BEEF: [] }),
115
+ createHmac: jest.fn().mockResolvedValue({ hmac: new Uint8Array([1, 2, 3, 4]) }),
116
+ decrypt: jest.fn().mockResolvedValue({ plaintext: new Uint8Array() }),
117
+ encrypt: jest.fn().mockResolvedValue({ ciphertext: new Uint8Array([5, 6, 7, 8]) })
59
118
  }
60
119
 
61
120
  identityClient = new IdentityClient(walletMock as WalletInterface)
@@ -275,4 +334,269 @@ describe('IdentityClient', () => {
275
334
  })
276
335
  })
277
336
  })
337
+
338
+ describe('ContactsManager Integration', () => {
339
+ const mockContact = {
340
+ name: 'Alice Smith',
341
+ identityKey: 'abcdef1234567890abcdef1234567890',
342
+ avatarURL: 'https://example.com/avatar.jpg',
343
+ abbreviatedKey: 'abcdef1234...',
344
+ badgeLabel: 'Verified User',
345
+ badgeIconURL: 'https://example.com/badge.png',
346
+ badgeClickURL: 'https://example.com/verify'
347
+ }
348
+
349
+ const mockContactWithMetadata = {
350
+ ...mockContact,
351
+ metadata: { notes: 'Met at conference' }
352
+ }
353
+
354
+ beforeEach(() => {
355
+ // Clear localStorage mocks
356
+ ; (localStorage.getItem as jest.Mock).mockClear()
357
+ ; (localStorage.setItem as jest.Mock).mockClear()
358
+ })
359
+
360
+ describe('saveContact', () => {
361
+ it('should save a contact without metadata', async () => {
362
+ // Mock empty contacts list (new contact)
363
+ ; (walletMock.listOutputs as jest.Mock).mockResolvedValue({
364
+ outputs: [],
365
+ BEEF: []
366
+ })
367
+
368
+ await identityClient.saveContact(mockContact)
369
+
370
+ // Verify cache was updated
371
+ expect(localStorage.setItem).toHaveBeenCalledWith(
372
+ 'metanet-contacts',
373
+ JSON.stringify([mockContact])
374
+ )
375
+
376
+ // Verify HMAC was created for tagging
377
+ expect(walletMock.createHmac).toHaveBeenCalledWith({
378
+ protocolID: [2, 'contact'],
379
+ keyID: mockContact.identityKey,
380
+ counterparty: 'self',
381
+ data: expect.any(Uint8Array)
382
+ })
383
+
384
+ // Verify contact data was encrypted
385
+ expect(walletMock.encrypt).toHaveBeenCalledWith({
386
+ plaintext: expect.any(Uint8Array),
387
+ protocolID: [2, 'contact'],
388
+ keyID: expect.any(String),
389
+ counterparty: 'self'
390
+ })
391
+
392
+ // Verify transaction was created
393
+ expect(walletMock.createAction).toHaveBeenCalledWith(
394
+ expect.objectContaining({
395
+ description: 'Add Contact',
396
+ outputs: expect.arrayContaining([
397
+ expect.objectContaining({
398
+ basket: 'contacts',
399
+ outputDescription: `Contact: ${mockContact.name}`
400
+ })
401
+ ])
402
+ })
403
+ )
404
+ })
405
+
406
+ it('should save a contact with metadata', async () => {
407
+ ; (walletMock.listOutputs as jest.Mock).mockResolvedValue({
408
+ outputs: [],
409
+ BEEF: []
410
+ })
411
+
412
+ await identityClient.saveContact(mockContact, { notes: 'Met at conference' })
413
+
414
+ // Verify cache includes metadata
415
+ expect(localStorage.setItem).toHaveBeenCalledWith(
416
+ 'metanet-contacts',
417
+ JSON.stringify([mockContactWithMetadata])
418
+ )
419
+ })
420
+
421
+ it('should update existing contact', async () => {
422
+ const existingOutput = {
423
+ outpoint: 'txid.0',
424
+ customInstructions: JSON.stringify({ keyID: 'existingKeyID' })
425
+ }
426
+
427
+ // Mock existing contact found - return on specific query with tags
428
+ ; (walletMock.listOutputs as jest.Mock)
429
+ .mockResolvedValueOnce({
430
+ outputs: [existingOutput],
431
+ BEEF: [1, 2, 3]
432
+ })
433
+
434
+ // Mock decrypt returning existing contact with same identityKey
435
+ ; (walletMock.decrypt as jest.Mock).mockResolvedValue({
436
+ plaintext: new TextEncoder().encode(JSON.stringify(mockContact))
437
+ })
438
+
439
+ const updatedContact = { ...mockContact, name: 'Alice Updated' }
440
+ await identityClient.saveContact(updatedContact)
441
+
442
+ // Since identityKey matches, should create update action
443
+ expect(walletMock.createAction).toHaveBeenCalledWith(
444
+ expect.objectContaining({
445
+ description: 'Update Contact',
446
+ inputBEEF: [1, 2, 3],
447
+ inputs: expect.arrayContaining([
448
+ expect.objectContaining({
449
+ outpoint: 'txid.0'
450
+ })
451
+ ])
452
+ })
453
+ )
454
+ })
455
+ })
456
+
457
+ describe('getContacts', () => {
458
+ it('should return cached contacts when available', async () => {
459
+ const cachedContacts = [mockContact]
460
+ ; (localStorage.getItem as jest.Mock).mockReturnValue(
461
+ JSON.stringify(cachedContacts)
462
+ )
463
+
464
+ const result = await identityClient.getContacts()
465
+
466
+ expect(result).toEqual(cachedContacts)
467
+ expect(walletMock.listOutputs).not.toHaveBeenCalled()
468
+ })
469
+
470
+ it('should load contacts from wallet basket when cache is empty', async () => {
471
+ ; (localStorage.getItem as jest.Mock).mockReturnValue(null)
472
+
473
+ const mockOutput = {
474
+ outpoint: 'txid.0',
475
+ customInstructions: JSON.stringify({ keyID: 'mockKeyID' })
476
+ }
477
+
478
+ ; (walletMock.listOutputs as jest.Mock).mockResolvedValue({
479
+ outputs: [mockOutput],
480
+ BEEF: [1, 2, 3]
481
+ })
482
+
483
+ // Mock decrypt to return the contact data directly as it would be stored
484
+ ; (walletMock.decrypt as jest.Mock).mockResolvedValue({
485
+ plaintext: new TextEncoder().encode(JSON.stringify(mockContact))
486
+ })
487
+
488
+ const result = await identityClient.getContacts()
489
+
490
+ expect(result).toEqual([mockContact])
491
+ expect(walletMock.listOutputs).toHaveBeenCalledWith({
492
+ basket: 'contacts',
493
+ include: 'entire transactions',
494
+ tags: []
495
+ })
496
+ expect(localStorage.setItem).toHaveBeenCalledWith(
497
+ 'metanet-contacts',
498
+ JSON.stringify([mockContact])
499
+ )
500
+ })
501
+
502
+ it('should force refresh when requested', async () => {
503
+ const cachedContacts = [mockContact]
504
+ ; (localStorage.getItem as jest.Mock).mockReturnValue(
505
+ JSON.stringify(cachedContacts)
506
+ )
507
+
508
+ ; (walletMock.listOutputs as jest.Mock).mockResolvedValue({
509
+ outputs: [],
510
+ BEEF: []
511
+ })
512
+
513
+ const result = await identityClient.getContacts(undefined, true)
514
+
515
+ expect(result).toEqual([])
516
+ expect(walletMock.listOutputs).toHaveBeenCalled()
517
+ })
518
+
519
+ it('should filter by identity key when provided', async () => {
520
+ const contacts = [mockContact, { ...mockContact, identityKey: 'different-key' }]
521
+ ; (localStorage.getItem as jest.Mock).mockReturnValue(
522
+ JSON.stringify(contacts)
523
+ )
524
+
525
+ const result = await identityClient.getContacts(mockContact.identityKey)
526
+
527
+ expect(result).toEqual([mockContact])
528
+ })
529
+
530
+ it('should throw error on listOutputs failure', async () => {
531
+ ; (localStorage.getItem as jest.Mock).mockReturnValue(
532
+ JSON.stringify([mockContact])
533
+ )
534
+ ; (walletMock.listOutputs as jest.Mock).mockRejectedValue(
535
+ new Error('List outputs error')
536
+ )
537
+
538
+ await expect(
539
+ identityClient.getContacts(undefined, true)
540
+ ).rejects.toThrow('List outputs error')
541
+ })
542
+ })
543
+
544
+ describe('removeContact', () => {
545
+ it('should remove contact from cache and spend UTXO', async () => {
546
+ const contacts = [mockContact, { ...mockContact, identityKey: 'other-key' }]
547
+ ; (localStorage.getItem as jest.Mock).mockReturnValue(
548
+ JSON.stringify(contacts)
549
+ )
550
+
551
+ const mockOutput = {
552
+ outpoint: 'txid.0',
553
+ customInstructions: JSON.stringify({ keyID: 'mockKeyID' })
554
+ }
555
+
556
+ ; (walletMock.listOutputs as jest.Mock).mockResolvedValue({
557
+ outputs: [mockOutput],
558
+ BEEF: [1, 2, 3]
559
+ })
560
+
561
+ // Mock decrypt to return the contact that matches the identityKey
562
+ ; (walletMock.decrypt as jest.Mock).mockResolvedValue({
563
+ plaintext: new TextEncoder().encode(JSON.stringify(mockContact))
564
+ })
565
+
566
+ await identityClient.removeContact(mockContact.identityKey)
567
+
568
+ // Verify cache was updated (contact removed)
569
+ expect(localStorage.setItem).toHaveBeenCalledWith(
570
+ 'metanet-contacts',
571
+ JSON.stringify([{ ...mockContact, identityKey: 'other-key' }])
572
+ )
573
+
574
+ // Verify delete action was created
575
+ expect(walletMock.createAction).toHaveBeenCalledWith(
576
+ expect.objectContaining({
577
+ description: 'Delete Contact',
578
+ inputBEEF: [1, 2, 3],
579
+ inputs: expect.arrayContaining([
580
+ expect.objectContaining({
581
+ outpoint: 'txid.0'
582
+ })
583
+ ]),
584
+ outputs: [] // No outputs for deletion
585
+ })
586
+ )
587
+ })
588
+
589
+ it('should handle contact not found gracefully', async () => {
590
+ ; (walletMock.listOutputs as jest.Mock).mockResolvedValue({
591
+ outputs: [],
592
+ BEEF: []
593
+ })
594
+
595
+ // Should not throw
596
+ await expect(
597
+ identityClient.removeContact('non-existent-key')
598
+ ).resolves.toBeUndefined()
599
+ })
600
+ })
601
+ })
278
602
  })
@@ -804,6 +804,8 @@ export default class Transaction {
804
804
  if (proofValid) {
805
805
  verifiedTxids.add(txid)
806
806
  continue
807
+ } else {
808
+ throw new Error(`Invalid merkle path for transaction ${txid}`)
807
809
  }
808
810
  }
809
811
  }