@bsv/sdk 1.6.25 → 1.7.0

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 (40) hide show
  1. package/dist/cjs/package.json +1 -1
  2. package/dist/cjs/src/identity/ContactsManager.js +50 -14
  3. package/dist/cjs/src/identity/ContactsManager.js.map +1 -1
  4. package/dist/cjs/src/storage/StorageDownloader.js +24 -10
  5. package/dist/cjs/src/storage/StorageDownloader.js.map +1 -1
  6. package/dist/cjs/src/storage/StorageUploader.js +5 -4
  7. package/dist/cjs/src/storage/StorageUploader.js.map +1 -1
  8. package/dist/cjs/src/storage/StorageUtils.js +11 -3
  9. package/dist/cjs/src/storage/StorageUtils.js.map +1 -1
  10. package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
  11. package/dist/esm/src/identity/ContactsManager.js +48 -14
  12. package/dist/esm/src/identity/ContactsManager.js.map +1 -1
  13. package/dist/esm/src/storage/StorageDownloader.js +24 -10
  14. package/dist/esm/src/storage/StorageDownloader.js.map +1 -1
  15. package/dist/esm/src/storage/StorageUploader.js +5 -4
  16. package/dist/esm/src/storage/StorageUploader.js.map +1 -1
  17. package/dist/esm/src/storage/StorageUtils.js +11 -3
  18. package/dist/esm/src/storage/StorageUtils.js.map +1 -1
  19. package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
  20. package/dist/types/src/identity/ContactsManager.d.ts +2 -0
  21. package/dist/types/src/identity/ContactsManager.d.ts.map +1 -1
  22. package/dist/types/src/storage/StorageDownloader.d.ts +1 -1
  23. package/dist/types/src/storage/StorageDownloader.d.ts.map +1 -1
  24. package/dist/types/src/storage/StorageUploader.d.ts +1 -1
  25. package/dist/types/src/storage/StorageUploader.d.ts.map +1 -1
  26. package/dist/types/src/storage/StorageUtils.d.ts +3 -2
  27. package/dist/types/src/storage/StorageUtils.d.ts.map +1 -1
  28. package/dist/types/tsconfig.types.tsbuildinfo +1 -1
  29. package/dist/umd/bundle.js +3 -3
  30. package/dist/umd/bundle.js.map +1 -1
  31. package/docs/reference/storage.md +12 -5
  32. package/docs/tutorials/uhrp-storage.md +6 -6
  33. package/package.json +1 -1
  34. package/src/identity/ContactsManager.ts +54 -14
  35. package/src/identity/__tests/IdentityClient.test.ts +119 -85
  36. package/src/storage/StorageDownloader.ts +28 -12
  37. package/src/storage/StorageUploader.ts +6 -5
  38. package/src/storage/StorageUtils.ts +12 -4
  39. package/src/storage/__tests/StorageDownloader.test.ts +34 -2
  40. package/src/storage/__tests/StorageUploader.test.ts +20 -0
@@ -22,7 +22,7 @@ Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](
22
22
 
23
23
  ```ts
24
24
  export interface DownloadResult {
25
- data: number[];
25
+ data: Uint8Array;
26
26
  mimeType: string | null;
27
27
  }
28
28
  ```
@@ -85,7 +85,7 @@ Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](
85
85
 
86
86
  ```ts
87
87
  export interface UploadableFile {
88
- data: number[];
88
+ data: Uint8Array | number[];
89
89
  type: string;
90
90
  }
91
91
  ```
@@ -341,13 +341,20 @@ Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](
341
341
  ### Variable: getURLForFile
342
342
 
343
343
  ```ts
344
- getURLForFile = (file: number[]): string => {
345
- const hash = sha256(file);
344
+ getURLForFile = (file: Uint8Array | number[]): string => {
345
+ const data = file instanceof Uint8Array ? file : Uint8Array.from(file);
346
+ const hasher = new Hash.SHA256();
347
+ const chunkSize = 1024 * 1024;
348
+ for (let i = 0; i < data.length; i += chunkSize) {
349
+ const chunk = data.subarray(i, i + chunkSize);
350
+ hasher.update(Array.from(chunk));
351
+ }
352
+ const hash = hasher.digest();
346
353
  return getURLForHash(hash);
347
354
  }
348
355
  ```
349
356
 
350
- See also: [getURLForHash](./storage.md#variable-geturlforhash), [sha256](./primitives.md#variable-sha256)
357
+ See also: [getURLForHash](./storage.md#variable-geturlforhash)
351
358
 
352
359
  Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables)
353
360
 
@@ -71,7 +71,7 @@ async function basicFileUpload() {
71
71
  // Create sample file
72
72
  const fileData = new TextEncoder().encode('Hello, UHRP storage!')
73
73
  const file = {
74
- data: Array.from(fileData),
74
+ data: fileData,
75
75
  type: 'text/plain'
76
76
  }
77
77
 
@@ -116,7 +116,7 @@ async function basicFileDownload(uhrpUrl: string) {
116
116
 
117
117
  // Convert to string if text file
118
118
  if (result.mimeType?.startsWith('text/')) {
119
- const content = new TextDecoder().decode(new Uint8Array(result.data))
119
+ const content = new TextDecoder().decode(result.data)
120
120
  console.log('Content:', content)
121
121
  }
122
122
 
@@ -172,7 +172,7 @@ class UHRPFileManager {
172
172
  tags: string[] = []
173
173
  ): Promise<FileMetadata> {
174
174
  const file = {
175
- data: Array.from(fileData),
175
+ data: fileData,
176
176
  type: mimeType
177
177
  }
178
178
 
@@ -217,7 +217,7 @@ class UHRPFileManager {
217
217
  console.log('File downloaded:', uhrpUrl)
218
218
 
219
219
  return {
220
- data: new Uint8Array(result.data),
220
+ data: result.data,
221
221
  metadata
222
222
  }
223
223
  } catch (error) {
@@ -371,7 +371,7 @@ class BatchFileOperations {
371
371
  const results = await Promise.allSettled(
372
372
  files.map(async (file) => {
373
373
  const fileObj = {
374
- data: Array.from(file.data),
374
+ data: file.data,
375
375
  type: file.type
376
376
  }
377
377
 
@@ -422,7 +422,7 @@ class BatchFileOperations {
422
422
  return {
423
423
  success: true,
424
424
  url,
425
- data: new Uint8Array(result.value.data)
425
+ data: result.value.data
426
426
  }
427
427
  } else {
428
428
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bsv/sdk",
3
- "version": "1.6.25",
3
+ "version": "1.7.0",
4
4
  "type": "module",
5
5
  "description": "BSV Blockchain Software Development Kit",
6
6
  "main": "dist/cjs/mod.js",
@@ -6,11 +6,32 @@ import { Transaction } from '../transaction/index.js'
6
6
  export type Contact = DisplayableIdentity & { metadata?: Record<string, any> }
7
7
 
8
8
  const CONTACT_PROTOCOL_ID: WalletProtocol = [2, 'contact']
9
- // Local cache key for performance
10
- const CONTACTS_CACHE_KEY = 'metanet-contacts'
9
+
10
+ // In-memory cache for cross-platform compatibility
11
+ class MemoryCache {
12
+ private readonly cache = new Map<string, string>()
13
+
14
+ getItem (key: string): string | null {
15
+ return this.cache.get(key) ?? null
16
+ }
17
+
18
+ setItem (key: string, value: string): void {
19
+ this.cache.set(key, value)
20
+ }
21
+
22
+ removeItem (key: string): void {
23
+ this.cache.delete(key)
24
+ }
25
+
26
+ clear (): void {
27
+ this.cache.clear()
28
+ }
29
+ }
11
30
 
12
31
  export class ContactsManager {
13
32
  private readonly wallet: WalletInterface
33
+ private readonly cache = new MemoryCache()
34
+ private readonly CONTACTS_CACHE_KEY = 'metanet-contacts'
14
35
 
15
36
  constructor (wallet?: WalletInterface) {
16
37
  this.wallet = wallet ?? new WalletClient()
@@ -23,9 +44,9 @@ export class ContactsManager {
23
44
  * @returns A promise that resolves with an array of contacts
24
45
  */
25
46
  async getContacts (identityKey?: PubKeyHex, forceRefresh = false): Promise<Contact[]> {
26
- // Check localStorage cache first unless forcing refresh
47
+ // Check in-memory cache first unless forcing refresh
27
48
  if (!forceRefresh) {
28
- const cached = localStorage.getItem(CONTACTS_CACHE_KEY)
49
+ const cached = this.cache.getItem(this.CONTACTS_CACHE_KEY)
29
50
  if (cached != null && cached !== '') {
30
51
  try {
31
52
  const cachedContacts: Contact[] = JSON.parse(cached)
@@ -54,11 +75,12 @@ export class ContactsManager {
54
75
  const outputs = await this.wallet.listOutputs({
55
76
  basket: 'contacts',
56
77
  include: 'entire transactions',
78
+ includeCustomInstructions: true,
57
79
  tags
58
80
  })
59
81
 
60
82
  if (outputs.outputs == null || outputs.outputs.length === 0) {
61
- localStorage.setItem(CONTACTS_CACHE_KEY, JSON.stringify([]))
83
+ this.cache.setItem(this.CONTACTS_CACHE_KEY, JSON.stringify([]))
62
84
  return []
63
85
  }
64
86
 
@@ -95,7 +117,7 @@ export class ContactsManager {
95
117
  }
96
118
 
97
119
  // Cache the loaded contacts
98
- localStorage.setItem(CONTACTS_CACHE_KEY, JSON.stringify(contacts))
120
+ this.cache.setItem(this.CONTACTS_CACHE_KEY, JSON.stringify(contacts))
99
121
  const filteredContacts = identityKey != null
100
122
  ? contacts.filter(c => c.identityKey === identityKey)
101
123
  : contacts
@@ -108,9 +130,16 @@ export class ContactsManager {
108
130
  * @param metadata Optional metadata to store with the contact (ex. notes, aliases, etc)
109
131
  */
110
132
  async saveContact (contact: DisplayableIdentity, metadata?: Record<string, any>): Promise<void> {
111
- // Update localStorage cache immediately
112
- const cached = localStorage.getItem(CONTACTS_CACHE_KEY)
113
- const contacts: Contact[] = (cached != null && cached !== '') ? JSON.parse(cached) : []
133
+ // Get current contacts from cache or blockchain
134
+ const cached = this.cache.getItem(this.CONTACTS_CACHE_KEY)
135
+ let contacts: Contact[]
136
+ if (cached != null && cached !== '') {
137
+ contacts = JSON.parse(cached)
138
+ } else {
139
+ // If cache is empty, get current data from blockchain
140
+ contacts = await this.getContacts()
141
+ }
142
+
114
143
  const existingIndex = contacts.findIndex(c => c.identityKey === contact.identityKey)
115
144
  const contactToStore: Contact = {
116
145
  ...contact,
@@ -122,7 +151,6 @@ export class ContactsManager {
122
151
  } else {
123
152
  contacts.push(contactToStore)
124
153
  }
125
- localStorage.setItem(CONTACTS_CACHE_KEY, JSON.stringify(contacts))
126
154
 
127
155
  const { hmac: hashedIdentityKey } = await this.wallet.createHmac({
128
156
  protocolID: CONTACT_PROTOCOL_ID,
@@ -246,6 +274,7 @@ export class ContactsManager {
246
274
 
247
275
  if (tx == null) throw new Error('Failed to create contact output')
248
276
  }
277
+ this.cache.setItem(this.CONTACTS_CACHE_KEY, JSON.stringify(contacts))
249
278
  }
250
279
 
251
280
  /**
@@ -253,23 +282,34 @@ export class ContactsManager {
253
282
  * @param identityKey The identity key of the contact to remove
254
283
  */
255
284
  async removeContact (identityKey: string): Promise<void> {
256
- // Update localStorage cache
257
- const cached = localStorage.getItem(CONTACTS_CACHE_KEY)
285
+ // Update in-memory cache
286
+ const cached = this.cache.getItem(this.CONTACTS_CACHE_KEY)
258
287
  if (cached != null && cached !== '') {
259
288
  try {
260
289
  const contacts: Contact[] = JSON.parse(cached)
261
290
  const filteredContacts = contacts.filter(c => c.identityKey !== identityKey)
262
- localStorage.setItem(CONTACTS_CACHE_KEY, JSON.stringify(filteredContacts))
291
+ this.cache.setItem(this.CONTACTS_CACHE_KEY, JSON.stringify(filteredContacts))
263
292
  } catch (e) {
264
293
  console.warn('Failed to update cache after contact removal:', e)
265
294
  }
266
295
  }
267
296
 
297
+ // Hash the identity key to use as a tag for quick lookup
298
+ const tags: string[] = []
299
+ const { hmac: hashedIdentityKey } = await this.wallet.createHmac({
300
+ protocolID: CONTACT_PROTOCOL_ID,
301
+ keyID: identityKey,
302
+ counterparty: 'self',
303
+ data: Utils.toArray(identityKey, 'utf8')
304
+ })
305
+ tags.push(`identityKey ${Utils.toHex(hashedIdentityKey)}`)
306
+
268
307
  // Find and spend the contact's output
269
308
  const outputs = await this.wallet.listOutputs({
270
309
  basket: 'contacts',
271
310
  include: 'entire transactions',
272
- includeCustomInstructions: true
311
+ includeCustomInstructions: true,
312
+ tags
273
313
  })
274
314
 
275
315
  if (outputs.outputs == null) return
@@ -352,9 +352,8 @@ describe('IdentityClient', () => {
352
352
  }
353
353
 
354
354
  beforeEach(() => {
355
- // Clear localStorage mocks
356
- ; (localStorage.getItem as jest.Mock).mockClear()
357
- ; (localStorage.setItem as jest.Mock).mockClear()
355
+ // Reset wallet mocks for each test
356
+ jest.clearAllMocks()
358
357
  })
359
358
 
360
359
  describe('saveContact', () => {
@@ -367,12 +366,6 @@ describe('IdentityClient', () => {
367
366
 
368
367
  await identityClient.saveContact(mockContact)
369
368
 
370
- // Verify cache was updated
371
- expect(localStorage.setItem).toHaveBeenCalledWith(
372
- 'metanet-contacts',
373
- JSON.stringify([mockContact])
374
- )
375
-
376
369
  // Verify HMAC was created for tagging
377
370
  expect(walletMock.createHmac).toHaveBeenCalledWith({
378
371
  protocolID: [2, 'contact'],
@@ -389,7 +382,7 @@ describe('IdentityClient', () => {
389
382
  counterparty: 'self'
390
383
  })
391
384
 
392
- // Verify transaction was created
385
+ // Verify new contact transaction was created
393
386
  expect(walletMock.createAction).toHaveBeenCalledWith(
394
387
  expect.objectContaining({
395
388
  description: 'Add Contact',
@@ -401,6 +394,13 @@ describe('IdentityClient', () => {
401
394
  ])
402
395
  })
403
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
404
  })
405
405
 
406
406
  it('should save a contact with metadata', async () => {
@@ -411,36 +411,43 @@ describe('IdentityClient', () => {
411
411
 
412
412
  await identityClient.saveContact(mockContact, { notes: 'Met at conference' })
413
413
 
414
- // Verify cache includes metadata
415
- expect(localStorage.setItem).toHaveBeenCalledWith(
416
- 'metanet-contacts',
417
- JSON.stringify([mockContactWithMetadata])
418
- )
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
+ }))
419
421
  })
420
422
 
421
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
422
432
  const existingOutput = {
423
433
  outpoint: 'txid.0',
424
434
  customInstructions: JSON.stringify({ keyID: 'existingKeyID' })
425
435
  }
426
436
 
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
- })
437
+ ; (walletMock.listOutputs as jest.Mock).mockResolvedValueOnce({
438
+ outputs: [existingOutput],
439
+ BEEF: [1, 2, 3]
440
+ })
433
441
 
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
- })
442
+ ; (walletMock.decrypt as jest.Mock).mockResolvedValue({
443
+ plaintext: new TextEncoder().encode(JSON.stringify(mockContact))
444
+ })
438
445
 
439
446
  const updatedContact = { ...mockContact, name: 'Alice Updated' }
440
447
  await identityClient.saveContact(updatedContact)
441
448
 
442
- // Since identityKey matches, should create update action
443
- expect(walletMock.createAction).toHaveBeenCalledWith(
449
+ // Should create update action since contact exists
450
+ expect(walletMock.createAction).toHaveBeenLastCalledWith(
444
451
  expect.objectContaining({
445
452
  description: 'Update Contact',
446
453
  inputBEEF: [1, 2, 3],
@@ -456,34 +463,40 @@ describe('IdentityClient', () => {
456
463
 
457
464
  describe('getContacts', () => {
458
465
  it('should return cached contacts when available', async () => {
459
- const cachedContacts = [mockContact]
460
- ; (localStorage.getItem as jest.Mock).mockReturnValue(
461
- JSON.stringify(cachedContacts)
462
- )
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)
463
472
 
473
+ // Clear mocks to verify cache is used
474
+ jest.clearAllMocks()
475
+
476
+ // Get contacts should use cache and not call wallet
464
477
  const result = await identityClient.getContacts()
465
478
 
466
- expect(result).toEqual(cachedContacts)
479
+ expect(result).toContainEqual(expect.objectContaining({
480
+ name: mockContact.name,
481
+ identityKey: mockContact.identityKey
482
+ }))
467
483
  expect(walletMock.listOutputs).not.toHaveBeenCalled()
468
484
  })
469
485
 
470
486
  it('should load contacts from wallet basket when cache is empty', async () => {
471
- ; (localStorage.getItem as jest.Mock).mockReturnValue(null)
472
-
473
487
  const mockOutput = {
474
488
  outpoint: 'txid.0',
475
489
  customInstructions: JSON.stringify({ keyID: 'mockKeyID' })
476
490
  }
477
491
 
478
- ; (walletMock.listOutputs as jest.Mock).mockResolvedValue({
479
- outputs: [mockOutput],
480
- BEEF: [1, 2, 3]
481
- })
492
+ ; (walletMock.listOutputs as jest.Mock).mockResolvedValue({
493
+ outputs: [mockOutput],
494
+ BEEF: [1, 2, 3]
495
+ })
482
496
 
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
- })
497
+ ; (walletMock.decrypt as jest.Mock).mockResolvedValue({
498
+ plaintext: new TextEncoder().encode(JSON.stringify(mockContact))
499
+ })
487
500
 
488
501
  const result = await identityClient.getContacts()
489
502
 
@@ -491,24 +504,30 @@ describe('IdentityClient', () => {
491
504
  expect(walletMock.listOutputs).toHaveBeenCalledWith({
492
505
  basket: 'contacts',
493
506
  include: 'entire transactions',
507
+ includeCustomInstructions: true,
494
508
  tags: []
495
509
  })
496
- expect(localStorage.setItem).toHaveBeenCalledWith(
497
- 'metanet-contacts',
498
- JSON.stringify([mockContact])
499
- )
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()
500
516
  })
501
517
 
502
518
  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
- })
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
+ })
512
531
 
513
532
  const result = await identityClient.getContacts(undefined, true)
514
533
 
@@ -517,23 +536,30 @@ describe('IdentityClient', () => {
517
536
  })
518
537
 
519
538
  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
- )
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)
524
548
 
549
+ // Filter by specific identity key
525
550
  const result = await identityClient.getContacts(mockContact.identityKey)
526
551
 
527
- expect(result).toEqual([mockContact])
552
+ expect(result).toEqual([expect.objectContaining({
553
+ name: mockContact.name,
554
+ identityKey: mockContact.identityKey
555
+ })])
556
+ expect(result).toHaveLength(1)
528
557
  })
529
558
 
530
559
  it('should throw error on listOutputs failure', async () => {
531
- ; (localStorage.getItem as jest.Mock).mockReturnValue(
532
- JSON.stringify([mockContact])
560
+ ; (walletMock.listOutputs as jest.Mock).mockRejectedValue(
561
+ new Error('List outputs error')
533
562
  )
534
- ; (walletMock.listOutputs as jest.Mock).mockRejectedValue(
535
- new Error('List outputs error')
536
- )
537
563
 
538
564
  await expect(
539
565
  identityClient.getContacts(undefined, true)
@@ -543,36 +569,35 @@ describe('IdentityClient', () => {
543
569
 
544
570
  describe('removeContact', () => {
545
571
  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
- )
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)
550
581
 
582
+ // Mock finding the contact to remove
551
583
  const mockOutput = {
552
584
  outpoint: 'txid.0',
553
585
  customInstructions: JSON.stringify({ keyID: 'mockKeyID' })
554
586
  }
555
587
 
556
- ; (walletMock.listOutputs as jest.Mock).mockResolvedValue({
557
- outputs: [mockOutput],
558
- BEEF: [1, 2, 3]
559
- })
588
+ ; (walletMock.listOutputs as jest.Mock).mockResolvedValue({
589
+ outputs: [mockOutput],
590
+ BEEF: [1, 2, 3]
591
+ })
560
592
 
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
- })
593
+ ; (walletMock.decrypt as jest.Mock).mockResolvedValue({
594
+ plaintext: new TextEncoder().encode(JSON.stringify(mockContact))
595
+ })
565
596
 
566
597
  await identityClient.removeContact(mockContact.identityKey)
567
598
 
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
599
  // Verify delete action was created
575
- expect(walletMock.createAction).toHaveBeenCalledWith(
600
+ expect(walletMock.createAction).toHaveBeenLastCalledWith(
576
601
  expect.objectContaining({
577
602
  description: 'Delete Contact',
578
603
  inputBEEF: [1, 2, 3],
@@ -584,6 +609,12 @@ describe('IdentityClient', () => {
584
609
  outputs: [] // No outputs for deletion
585
610
  })
586
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
+ )
587
618
  })
588
619
 
589
620
  it('should handle contact not found gracefully', async () => {
@@ -592,10 +623,13 @@ describe('IdentityClient', () => {
592
623
  BEEF: []
593
624
  })
594
625
 
595
- // Should not throw
626
+ // Should not throw when contact doesn't exist
596
627
  await expect(
597
628
  identityClient.removeContact('non-existent-key')
598
629
  ).resolves.toBeUndefined()
630
+
631
+ // Should not call createAction since no contact found
632
+ expect(walletMock.createAction).not.toHaveBeenCalled()
599
633
  })
600
634
  })
601
635
  })
@@ -9,7 +9,7 @@ export interface DownloaderConfig {
9
9
  }
10
10
 
11
11
  export interface DownloadResult {
12
- data: number[]
12
+ data: Uint8Array
13
13
  mimeType: string | null
14
14
  }
15
15
 
@@ -58,6 +58,7 @@ export class StorageDownloader {
58
58
  throw new Error('Invalid parameter UHRP url')
59
59
  }
60
60
  const hash = StorageUtils.getHashFromURL(uhrpUrl)
61
+ const expected = Utils.toHex(hash)
61
62
  const downloadURLs = await this.resolve(uhrpUrl)
62
63
 
63
64
  if (!Array.isArray(downloadURLs) || downloadURLs.length === 0) {
@@ -70,22 +71,37 @@ export class StorageDownloader {
70
71
  const result = await fetch(downloadURLs[i], { method: 'GET' })
71
72
 
72
73
  // If the request fails, continue to the next url
73
- if (!result.ok || result.status >= 400) {
74
+ if (!result.ok || result.status >= 400 || result.body == null) {
74
75
  continue
75
76
  }
76
- const body = await result.arrayBuffer()
77
-
78
- // The body is loaded into a number array
79
- const content: number[] = [...new Uint8Array(body)]
80
- const contentHash = Hash.sha256(content)
81
- for (let i = 0; i < contentHash.length; ++i) {
82
- if (contentHash[i] !== hash[i]) {
83
- throw new Error('Value of content does not match hash of the url given')
84
- }
77
+
78
+ const reader = result.body.getReader()
79
+ const hashStream = new Hash.SHA256()
80
+ const chunks: Uint8Array[] = []
81
+ let totalLength = 0
82
+
83
+ while (true) {
84
+ const { done, value } = await reader.read()
85
+ if (done) break
86
+ hashStream.update(Array.from(value))
87
+ chunks.push(value)
88
+ totalLength += value.length
89
+ }
90
+
91
+ const digest = Utils.toHex(hashStream.digest())
92
+ if (digest !== expected) {
93
+ throw new Error('Data integrity error: value of content does not match hash of the url given')
94
+ }
95
+
96
+ const data = new Uint8Array(totalLength)
97
+ let offset = 0
98
+ for (const chunk of chunks) {
99
+ data.set(chunk, offset)
100
+ offset += chunk.length
85
101
  }
86
102
 
87
103
  return {
88
- data: content,
104
+ data,
89
105
  mimeType: result.headers.get('Content-Type')
90
106
  }
91
107
  } catch (error) {