@bsv/sdk 1.6.24 → 1.6.26
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/dist/cjs/package.json +1 -1
- package/dist/cjs/src/identity/ContactsManager.js +334 -0
- package/dist/cjs/src/identity/ContactsManager.js.map +1 -0
- package/dist/cjs/src/identity/IdentityClient.js +26 -0
- package/dist/cjs/src/identity/IdentityClient.js.map +1 -1
- package/dist/cjs/src/transaction/Transaction.js +3 -0
- package/dist/cjs/src/transaction/Transaction.js.map +1 -1
- package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
- package/dist/esm/src/identity/ContactsManager.js +329 -0
- package/dist/esm/src/identity/ContactsManager.js.map +1 -0
- package/dist/esm/src/identity/IdentityClient.js +27 -0
- package/dist/esm/src/identity/IdentityClient.js.map +1 -1
- package/dist/esm/src/transaction/Transaction.js +3 -0
- package/dist/esm/src/transaction/Transaction.js.map +1 -1
- package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
- package/dist/types/src/identity/ContactsManager.d.ts +30 -0
- package/dist/types/src/identity/ContactsManager.d.ts.map +1 -0
- package/dist/types/src/identity/IdentityClient.d.ts +21 -1
- package/dist/types/src/identity/IdentityClient.d.ts.map +1 -1
- package/dist/types/src/transaction/Transaction.d.ts.map +1 -1
- package/dist/types/tsconfig.types.tsbuildinfo +1 -1
- package/dist/umd/bundle.js +3 -3
- package/dist/umd/bundle.js.map +1 -1
- package/docs/reference/identity.md +144 -4
- package/package.json +1 -1
- package/src/identity/ContactsManager.ts +372 -0
- package/src/identity/IdentityClient.ts +31 -0
- package/src/identity/__tests/IdentityClient.test.ts +360 -2
- 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,303 @@ 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
|
+
// Reset wallet mocks for each test
|
|
356
|
+
jest.clearAllMocks()
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
describe('saveContact', () => {
|
|
360
|
+
it('should save a contact without metadata', async () => {
|
|
361
|
+
// Mock empty contacts list (new contact)
|
|
362
|
+
; (walletMock.listOutputs as jest.Mock).mockResolvedValue({
|
|
363
|
+
outputs: [],
|
|
364
|
+
BEEF: []
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
await identityClient.saveContact(mockContact)
|
|
368
|
+
|
|
369
|
+
// Verify HMAC was created for tagging
|
|
370
|
+
expect(walletMock.createHmac).toHaveBeenCalledWith({
|
|
371
|
+
protocolID: [2, 'contact'],
|
|
372
|
+
keyID: mockContact.identityKey,
|
|
373
|
+
counterparty: 'self',
|
|
374
|
+
data: expect.any(Uint8Array)
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
// Verify contact data was encrypted
|
|
378
|
+
expect(walletMock.encrypt).toHaveBeenCalledWith({
|
|
379
|
+
plaintext: expect.any(Uint8Array),
|
|
380
|
+
protocolID: [2, 'contact'],
|
|
381
|
+
keyID: expect.any(String),
|
|
382
|
+
counterparty: 'self'
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
// Verify new contact transaction was created
|
|
386
|
+
expect(walletMock.createAction).toHaveBeenCalledWith(
|
|
387
|
+
expect.objectContaining({
|
|
388
|
+
description: 'Add Contact',
|
|
389
|
+
outputs: expect.arrayContaining([
|
|
390
|
+
expect.objectContaining({
|
|
391
|
+
basket: 'contacts',
|
|
392
|
+
outputDescription: `Contact: ${mockContact.name}`
|
|
393
|
+
})
|
|
394
|
+
])
|
|
395
|
+
})
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
// Verify contact is now available from cache
|
|
399
|
+
const contacts = await identityClient.getContacts()
|
|
400
|
+
expect(contacts).toContainEqual(expect.objectContaining({
|
|
401
|
+
name: mockContact.name,
|
|
402
|
+
identityKey: mockContact.identityKey
|
|
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 contact with metadata is available from cache
|
|
415
|
+
const contacts = await identityClient.getContacts()
|
|
416
|
+
expect(contacts).toContainEqual(expect.objectContaining({
|
|
417
|
+
name: mockContact.name,
|
|
418
|
+
identityKey: mockContact.identityKey,
|
|
419
|
+
metadata: { notes: 'Met at conference' }
|
|
420
|
+
}))
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
it('should update existing contact', async () => {
|
|
424
|
+
// First save a contact to establish it exists
|
|
425
|
+
; (walletMock.listOutputs as jest.Mock).mockResolvedValueOnce({
|
|
426
|
+
outputs: [],
|
|
427
|
+
BEEF: []
|
|
428
|
+
})
|
|
429
|
+
await identityClient.saveContact(mockContact)
|
|
430
|
+
|
|
431
|
+
// Now mock finding the existing contact for update
|
|
432
|
+
const existingOutput = {
|
|
433
|
+
outpoint: 'txid.0',
|
|
434
|
+
customInstructions: JSON.stringify({ keyID: 'existingKeyID' })
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
; (walletMock.listOutputs as jest.Mock).mockResolvedValueOnce({
|
|
438
|
+
outputs: [existingOutput],
|
|
439
|
+
BEEF: [1, 2, 3]
|
|
440
|
+
})
|
|
441
|
+
|
|
442
|
+
; (walletMock.decrypt as jest.Mock).mockResolvedValue({
|
|
443
|
+
plaintext: new TextEncoder().encode(JSON.stringify(mockContact))
|
|
444
|
+
})
|
|
445
|
+
|
|
446
|
+
const updatedContact = { ...mockContact, name: 'Alice Updated' }
|
|
447
|
+
await identityClient.saveContact(updatedContact)
|
|
448
|
+
|
|
449
|
+
// Should create update action since contact exists
|
|
450
|
+
expect(walletMock.createAction).toHaveBeenLastCalledWith(
|
|
451
|
+
expect.objectContaining({
|
|
452
|
+
description: 'Update Contact',
|
|
453
|
+
inputBEEF: [1, 2, 3],
|
|
454
|
+
inputs: expect.arrayContaining([
|
|
455
|
+
expect.objectContaining({
|
|
456
|
+
outpoint: 'txid.0'
|
|
457
|
+
})
|
|
458
|
+
])
|
|
459
|
+
})
|
|
460
|
+
)
|
|
461
|
+
})
|
|
462
|
+
})
|
|
463
|
+
|
|
464
|
+
describe('getContacts', () => {
|
|
465
|
+
it('should return cached contacts when available', async () => {
|
|
466
|
+
// First save a contact to populate cache
|
|
467
|
+
; (walletMock.listOutputs as jest.Mock).mockResolvedValue({
|
|
468
|
+
outputs: [],
|
|
469
|
+
BEEF: []
|
|
470
|
+
})
|
|
471
|
+
await identityClient.saveContact(mockContact)
|
|
472
|
+
|
|
473
|
+
// Clear mocks to verify cache is used
|
|
474
|
+
jest.clearAllMocks()
|
|
475
|
+
|
|
476
|
+
// Get contacts should use cache and not call wallet
|
|
477
|
+
const result = await identityClient.getContacts()
|
|
478
|
+
|
|
479
|
+
expect(result).toContainEqual(expect.objectContaining({
|
|
480
|
+
name: mockContact.name,
|
|
481
|
+
identityKey: mockContact.identityKey
|
|
482
|
+
}))
|
|
483
|
+
expect(walletMock.listOutputs).not.toHaveBeenCalled()
|
|
484
|
+
})
|
|
485
|
+
|
|
486
|
+
it('should load contacts from wallet basket when cache is empty', async () => {
|
|
487
|
+
const mockOutput = {
|
|
488
|
+
outpoint: 'txid.0',
|
|
489
|
+
customInstructions: JSON.stringify({ keyID: 'mockKeyID' })
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
; (walletMock.listOutputs as jest.Mock).mockResolvedValue({
|
|
493
|
+
outputs: [mockOutput],
|
|
494
|
+
BEEF: [1, 2, 3]
|
|
495
|
+
})
|
|
496
|
+
|
|
497
|
+
; (walletMock.decrypt as jest.Mock).mockResolvedValue({
|
|
498
|
+
plaintext: new TextEncoder().encode(JSON.stringify(mockContact))
|
|
499
|
+
})
|
|
500
|
+
|
|
501
|
+
const result = await identityClient.getContacts()
|
|
502
|
+
|
|
503
|
+
expect(result).toEqual([mockContact])
|
|
504
|
+
expect(walletMock.listOutputs).toHaveBeenCalledWith({
|
|
505
|
+
basket: 'contacts',
|
|
506
|
+
include: 'entire transactions',
|
|
507
|
+
includeCustomInstructions: true,
|
|
508
|
+
tags: []
|
|
509
|
+
})
|
|
510
|
+
|
|
511
|
+
// Verify subsequent call uses cache
|
|
512
|
+
jest.clearAllMocks()
|
|
513
|
+
const cachedResult = await identityClient.getContacts()
|
|
514
|
+
expect(cachedResult).toEqual([mockContact])
|
|
515
|
+
expect(walletMock.listOutputs).not.toHaveBeenCalled()
|
|
516
|
+
})
|
|
517
|
+
|
|
518
|
+
it('should force refresh when requested', async () => {
|
|
519
|
+
// First populate cache
|
|
520
|
+
; (walletMock.listOutputs as jest.Mock).mockResolvedValue({
|
|
521
|
+
outputs: [],
|
|
522
|
+
BEEF: []
|
|
523
|
+
})
|
|
524
|
+
await identityClient.saveContact(mockContact)
|
|
525
|
+
|
|
526
|
+
// Mock empty result for force refresh
|
|
527
|
+
; (walletMock.listOutputs as jest.Mock).mockResolvedValue({
|
|
528
|
+
outputs: [],
|
|
529
|
+
BEEF: []
|
|
530
|
+
})
|
|
531
|
+
|
|
532
|
+
const result = await identityClient.getContacts(undefined, true)
|
|
533
|
+
|
|
534
|
+
expect(result).toEqual([])
|
|
535
|
+
expect(walletMock.listOutputs).toHaveBeenCalled()
|
|
536
|
+
})
|
|
537
|
+
|
|
538
|
+
it('should filter by identity key when provided', async () => {
|
|
539
|
+
// Save two different contacts
|
|
540
|
+
; (walletMock.listOutputs as jest.Mock).mockResolvedValue({
|
|
541
|
+
outputs: [],
|
|
542
|
+
BEEF: []
|
|
543
|
+
})
|
|
544
|
+
await identityClient.saveContact(mockContact)
|
|
545
|
+
|
|
546
|
+
const otherContact = { ...mockContact, identityKey: 'different-key', name: 'Bob' }
|
|
547
|
+
await identityClient.saveContact(otherContact)
|
|
548
|
+
|
|
549
|
+
// Filter by specific identity key
|
|
550
|
+
const result = await identityClient.getContacts(mockContact.identityKey)
|
|
551
|
+
|
|
552
|
+
expect(result).toEqual([expect.objectContaining({
|
|
553
|
+
name: mockContact.name,
|
|
554
|
+
identityKey: mockContact.identityKey
|
|
555
|
+
})])
|
|
556
|
+
expect(result).toHaveLength(1)
|
|
557
|
+
})
|
|
558
|
+
|
|
559
|
+
it('should throw error on listOutputs failure', async () => {
|
|
560
|
+
; (walletMock.listOutputs as jest.Mock).mockRejectedValue(
|
|
561
|
+
new Error('List outputs error')
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
await expect(
|
|
565
|
+
identityClient.getContacts(undefined, true)
|
|
566
|
+
).rejects.toThrow('List outputs error')
|
|
567
|
+
})
|
|
568
|
+
})
|
|
569
|
+
|
|
570
|
+
describe('removeContact', () => {
|
|
571
|
+
it('should remove contact from cache and spend UTXO', async () => {
|
|
572
|
+
// First save two contacts
|
|
573
|
+
; (walletMock.listOutputs as jest.Mock).mockResolvedValue({
|
|
574
|
+
outputs: [],
|
|
575
|
+
BEEF: []
|
|
576
|
+
})
|
|
577
|
+
await identityClient.saveContact(mockContact)
|
|
578
|
+
|
|
579
|
+
const otherContact = { ...mockContact, identityKey: 'other-key', name: 'Bob' }
|
|
580
|
+
await identityClient.saveContact(otherContact)
|
|
581
|
+
|
|
582
|
+
// Mock finding the contact to remove
|
|
583
|
+
const mockOutput = {
|
|
584
|
+
outpoint: 'txid.0',
|
|
585
|
+
customInstructions: JSON.stringify({ keyID: 'mockKeyID' })
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
; (walletMock.listOutputs as jest.Mock).mockResolvedValue({
|
|
589
|
+
outputs: [mockOutput],
|
|
590
|
+
BEEF: [1, 2, 3]
|
|
591
|
+
})
|
|
592
|
+
|
|
593
|
+
; (walletMock.decrypt as jest.Mock).mockResolvedValue({
|
|
594
|
+
plaintext: new TextEncoder().encode(JSON.stringify(mockContact))
|
|
595
|
+
})
|
|
596
|
+
|
|
597
|
+
await identityClient.removeContact(mockContact.identityKey)
|
|
598
|
+
|
|
599
|
+
// Verify delete action was created
|
|
600
|
+
expect(walletMock.createAction).toHaveBeenLastCalledWith(
|
|
601
|
+
expect.objectContaining({
|
|
602
|
+
description: 'Delete Contact',
|
|
603
|
+
inputBEEF: [1, 2, 3],
|
|
604
|
+
inputs: expect.arrayContaining([
|
|
605
|
+
expect.objectContaining({
|
|
606
|
+
outpoint: 'txid.0'
|
|
607
|
+
})
|
|
608
|
+
]),
|
|
609
|
+
outputs: [] // No outputs for deletion
|
|
610
|
+
})
|
|
611
|
+
)
|
|
612
|
+
|
|
613
|
+
// Verify contact is removed from cache
|
|
614
|
+
const remainingContacts = await identityClient.getContacts()
|
|
615
|
+
expect(remainingContacts).not.toContainEqual(
|
|
616
|
+
expect.objectContaining({ identityKey: mockContact.identityKey })
|
|
617
|
+
)
|
|
618
|
+
})
|
|
619
|
+
|
|
620
|
+
it('should handle contact not found gracefully', async () => {
|
|
621
|
+
; (walletMock.listOutputs as jest.Mock).mockResolvedValue({
|
|
622
|
+
outputs: [],
|
|
623
|
+
BEEF: []
|
|
624
|
+
})
|
|
625
|
+
|
|
626
|
+
// Should not throw when contact doesn't exist
|
|
627
|
+
await expect(
|
|
628
|
+
identityClient.removeContact('non-existent-key')
|
|
629
|
+
).resolves.toBeUndefined()
|
|
630
|
+
|
|
631
|
+
// Should not call createAction since no contact found
|
|
632
|
+
expect(walletMock.createAction).not.toHaveBeenCalled()
|
|
633
|
+
})
|
|
634
|
+
})
|
|
635
|
+
})
|
|
278
636
|
})
|