@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.
- package/dist/cjs/package.json +1 -1
- package/dist/cjs/src/identity/ContactsManager.js +50 -14
- package/dist/cjs/src/identity/ContactsManager.js.map +1 -1
- package/dist/cjs/src/storage/StorageDownloader.js +24 -10
- package/dist/cjs/src/storage/StorageDownloader.js.map +1 -1
- package/dist/cjs/src/storage/StorageUploader.js +5 -4
- package/dist/cjs/src/storage/StorageUploader.js.map +1 -1
- package/dist/cjs/src/storage/StorageUtils.js +11 -3
- package/dist/cjs/src/storage/StorageUtils.js.map +1 -1
- package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
- package/dist/esm/src/identity/ContactsManager.js +48 -14
- package/dist/esm/src/identity/ContactsManager.js.map +1 -1
- package/dist/esm/src/storage/StorageDownloader.js +24 -10
- package/dist/esm/src/storage/StorageDownloader.js.map +1 -1
- package/dist/esm/src/storage/StorageUploader.js +5 -4
- package/dist/esm/src/storage/StorageUploader.js.map +1 -1
- package/dist/esm/src/storage/StorageUtils.js +11 -3
- package/dist/esm/src/storage/StorageUtils.js.map +1 -1
- package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
- package/dist/types/src/identity/ContactsManager.d.ts +2 -0
- package/dist/types/src/identity/ContactsManager.d.ts.map +1 -1
- package/dist/types/src/storage/StorageDownloader.d.ts +1 -1
- package/dist/types/src/storage/StorageDownloader.d.ts.map +1 -1
- package/dist/types/src/storage/StorageUploader.d.ts +1 -1
- package/dist/types/src/storage/StorageUploader.d.ts.map +1 -1
- package/dist/types/src/storage/StorageUtils.d.ts +3 -2
- package/dist/types/src/storage/StorageUtils.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/storage.md +12 -5
- package/docs/tutorials/uhrp-storage.md +6 -6
- package/package.json +1 -1
- package/src/identity/ContactsManager.ts +54 -14
- package/src/identity/__tests/IdentityClient.test.ts +119 -85
- package/src/storage/StorageDownloader.ts +28 -12
- package/src/storage/StorageUploader.ts +6 -5
- package/src/storage/StorageUtils.ts +12 -4
- package/src/storage/__tests/StorageDownloader.test.ts +34 -2
- 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:
|
|
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
|
|
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)
|
|
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:
|
|
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(
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
425
|
+
data: result.value.data
|
|
426
426
|
}
|
|
427
427
|
} else {
|
|
428
428
|
return {
|
package/package.json
CHANGED
|
@@ -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
|
-
|
|
10
|
-
|
|
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
|
|
47
|
+
// Check in-memory cache first unless forcing refresh
|
|
27
48
|
if (!forceRefresh) {
|
|
28
|
-
const cached =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
112
|
-
const cached =
|
|
113
|
-
|
|
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
|
|
257
|
-
const cached =
|
|
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
|
-
|
|
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
|
-
//
|
|
356
|
-
|
|
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
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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
|
-
//
|
|
443
|
-
expect(walletMock.createAction).
|
|
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
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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).
|
|
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
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
492
|
+
; (walletMock.listOutputs as jest.Mock).mockResolvedValue({
|
|
493
|
+
outputs: [mockOutput],
|
|
494
|
+
BEEF: [1, 2, 3]
|
|
495
|
+
})
|
|
482
496
|
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
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
|
-
|
|
497
|
-
|
|
498
|
-
|
|
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
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
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
|
-
|
|
521
|
-
|
|
522
|
-
|
|
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([
|
|
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
|
-
; (
|
|
532
|
-
|
|
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
|
-
|
|
547
|
-
|
|
548
|
-
|
|
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
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
588
|
+
; (walletMock.listOutputs as jest.Mock).mockResolvedValue({
|
|
589
|
+
outputs: [mockOutput],
|
|
590
|
+
BEEF: [1, 2, 3]
|
|
591
|
+
})
|
|
560
592
|
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
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).
|
|
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:
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
|
104
|
+
data,
|
|
89
105
|
mimeType: result.headers.get('Content-Type')
|
|
90
106
|
}
|
|
91
107
|
} catch (error) {
|