@ledgerhq/cryptoassets 13.33.0-nightly.20251125074637 → 13.34.0-nightly.20251126023856

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.
@@ -7,10 +7,14 @@ import {
7
7
  toTokenCurrencyRaw,
8
8
  fromTokenCurrencyRaw,
9
9
  extractTokensFromState,
10
+ extractHashesFromState,
11
+ extractPersistedCALFromState,
10
12
  filterExpiredTokens,
11
13
  restoreTokensToCache,
14
+ PERSISTENCE_VERSION,
12
15
  type TokenCurrencyRaw,
13
16
  type PersistedTokenEntry,
17
+ type PersistedCAL,
14
18
  type StateWithCryptoAssets,
15
19
  } from "./persistence";
16
20
  import { cryptoAssetsApi } from "./state-manager/api";
@@ -215,6 +219,24 @@ describe("Token Persistence", () => {
215
219
 
216
220
  expect(tokens).toEqual([]);
217
221
  });
222
+
223
+ it("should return empty array if no RTK Query state", () => {
224
+ const mockState = {} as unknown as StateWithCryptoAssets;
225
+
226
+ const tokens = extractTokensFromState(mockState);
227
+
228
+ expect(tokens).toEqual([]);
229
+ });
230
+
231
+ it("should return empty array if queries is undefined", () => {
232
+ const mockState = {
233
+ [cryptoAssetsApi.reducerPath]: {},
234
+ } as unknown as StateWithCryptoAssets;
235
+
236
+ const tokens = extractTokensFromState(mockState);
237
+
238
+ expect(tokens).toEqual([]);
239
+ });
218
240
  });
219
241
 
220
242
  describe("filterExpiredTokens", () => {
@@ -293,69 +315,505 @@ describe("Token Persistence", () => {
293
315
  });
294
316
 
295
317
  describe("restoreTokensToCache", () => {
296
- it("should restore valid tokens to RTK Query cache", () => {
318
+ it("should restore valid tokens to RTK Query cache", async () => {
297
319
  const mockDispatch = jest.fn();
298
320
  const now = Date.now();
299
321
  const ttl = 24 * 60 * 60 * 1000;
300
322
 
301
- const tokens: PersistedTokenEntry[] = [
302
- {
303
- data: toTokenCurrencyRaw(mockToken),
304
- timestamp: now,
305
- },
306
- ];
323
+ const persistedData: PersistedCAL = {
324
+ version: PERSISTENCE_VERSION,
325
+ tokens: [
326
+ {
327
+ data: toTokenCurrencyRaw(mockToken),
328
+ timestamp: now,
329
+ },
330
+ ],
331
+ };
307
332
 
308
- restoreTokensToCache(mockDispatch, tokens, ttl);
333
+ await restoreTokensToCache(mockDispatch, persistedData, ttl);
309
334
 
310
335
  // Should dispatch once with upsertQueryEntries (which contains both ID and address entries)
311
336
  expect(mockDispatch).toHaveBeenCalledTimes(1);
312
337
 
313
- // upsertQueryEntries returns action objects, not functions
314
338
  expect(mockDispatch).toHaveBeenCalled();
315
339
  });
316
340
 
317
- it("should skip expired tokens", () => {
341
+ it("should skip expired tokens", async () => {
318
342
  const mockDispatch = jest.fn();
319
343
  const now = Date.now();
320
344
  const ttl = 24 * 60 * 60 * 1000;
321
345
 
322
- const tokens: PersistedTokenEntry[] = [
323
- {
324
- data: toTokenCurrencyRaw(mockToken),
325
- timestamp: now - 25 * 60 * 60 * 1000, // Expired
326
- },
327
- ];
346
+ const persistedData: PersistedCAL = {
347
+ version: PERSISTENCE_VERSION,
348
+ tokens: [
349
+ {
350
+ data: toTokenCurrencyRaw(mockToken),
351
+ timestamp: now - 25 * 60 * 60 * 1000,
352
+ },
353
+ ],
354
+ };
328
355
 
329
- restoreTokensToCache(mockDispatch, tokens, ttl);
356
+ await restoreTokensToCache(mockDispatch, persistedData, ttl);
330
357
 
331
- // Should not dispatch anything for expired tokens
332
358
  expect(mockDispatch).not.toHaveBeenCalled();
333
359
  });
334
360
 
335
- it("should skip tokens with missing parent currency", () => {
361
+ it("should skip tokens with missing parent currency", async () => {
336
362
  const mockDispatch = jest.fn();
337
363
  const now = Date.now();
338
364
  const ttl = 24 * 60 * 60 * 1000;
339
365
 
340
- const tokens: PersistedTokenEntry[] = [
341
- {
342
- data: {
343
- id: "unknown/token/test",
344
- contractAddress: "0xabc",
345
- parentCurrencyId: "unknown_currency",
346
- tokenType: "erc20",
347
- name: "Test",
348
- ticker: "TEST",
349
- units: [{ name: "TEST", code: "TEST", magnitude: 18 }],
366
+ const persistedData: PersistedCAL = {
367
+ version: PERSISTENCE_VERSION,
368
+ tokens: [
369
+ {
370
+ data: {
371
+ id: "unknown/token/test",
372
+ contractAddress: "0xabc",
373
+ parentCurrencyId: "unknown_currency",
374
+ tokenType: "erc20",
375
+ name: "Test",
376
+ ticker: "TEST",
377
+ units: [{ name: "TEST", code: "TEST", magnitude: 18 }],
378
+ },
379
+ timestamp: now,
350
380
  },
351
- timestamp: now,
352
- },
353
- ];
381
+ ],
382
+ };
354
383
 
355
- restoreTokensToCache(mockDispatch, tokens, ttl);
384
+ await restoreTokensToCache(mockDispatch, persistedData, ttl);
356
385
 
357
- // Should not dispatch for tokens with missing parent
358
386
  expect(mockDispatch).not.toHaveBeenCalled();
359
387
  });
388
+
389
+ it("should restore tokens when hash matches", async () => {
390
+ const mockDispatch = jest.fn();
391
+ const now = Date.now();
392
+ const ttl = 24 * 60 * 60 * 1000;
393
+ const storedHash = "hash123";
394
+
395
+ mockDispatch.mockImplementation(async action => {
396
+ if (typeof action === "function") {
397
+ const thunkResult = await action(mockDispatch, () => ({}), undefined);
398
+ return thunkResult;
399
+ }
400
+ const actionStr = String(action);
401
+ if (
402
+ actionStr.includes("getTokensSyncHash") ||
403
+ (action as any)?.type?.includes("getTokensSyncHash")
404
+ ) {
405
+ return { data: storedHash, error: undefined };
406
+ }
407
+ return action;
408
+ });
409
+
410
+ const persistedData: PersistedCAL = {
411
+ version: PERSISTENCE_VERSION,
412
+ tokens: [
413
+ {
414
+ data: toTokenCurrencyRaw(mockToken),
415
+ timestamp: now,
416
+ },
417
+ ],
418
+ hashes: { ethereum: storedHash },
419
+ };
420
+
421
+ await restoreTokensToCache(mockDispatch, persistedData, ttl);
422
+
423
+ const invalidateCalls = mockDispatch.mock.calls.filter(call => {
424
+ const action = call[0];
425
+ if (!action) return false;
426
+ const callStr = String(action);
427
+ return (
428
+ callStr.includes("invalidateTags") || (action as any)?.type?.includes("invalidateTags")
429
+ );
430
+ });
431
+ expect(invalidateCalls).toHaveLength(0);
432
+ expect(mockDispatch).toHaveBeenCalled();
433
+ });
434
+
435
+ it("should skip restore when hash changed", async () => {
436
+ const mockDispatch = jest.fn();
437
+ const now = Date.now();
438
+ const ttl = 24 * 60 * 60 * 1000;
439
+ const storedHash = "hash123";
440
+ const currentHash = "hash456";
441
+ const upsertActions: any[] = [];
442
+
443
+ mockDispatch.mockImplementation(async action => {
444
+ if (typeof action === "function") {
445
+ const actionStr = String(action);
446
+ if (actionStr.includes("getTokensSyncHash")) {
447
+ return { data: currentHash, error: undefined };
448
+ }
449
+ const result = await action(mockDispatch, () => ({}), undefined);
450
+ return result;
451
+ }
452
+ const actionType = (action as any)?.type || "";
453
+ if (actionType.includes("upsertQueryEntries") || actionType.includes("upsert")) {
454
+ upsertActions.push(action);
455
+ }
456
+ return action;
457
+ });
458
+
459
+ const persistedData: PersistedCAL = {
460
+ version: PERSISTENCE_VERSION,
461
+ tokens: [
462
+ {
463
+ data: toTokenCurrencyRaw(mockToken),
464
+ timestamp: now,
465
+ },
466
+ ],
467
+ hashes: { ethereum: storedHash },
468
+ };
469
+
470
+ await restoreTokensToCache(mockDispatch, persistedData, ttl);
471
+
472
+ expect(upsertActions).toHaveLength(0);
473
+ });
474
+
475
+ it("should return early when all tokens are evicted after hash validation", async () => {
476
+ const mockDispatch = jest.fn();
477
+ const now = Date.now();
478
+ const ttl = 24 * 60 * 60 * 1000;
479
+ const storedHash = "hash123";
480
+ const currentHash = "hash456";
481
+ const upsertActions: any[] = [];
482
+
483
+ mockDispatch.mockImplementation(async action => {
484
+ if (typeof action === "function") {
485
+ const actionStr = String(action);
486
+ if (actionStr.includes("getTokensSyncHash")) {
487
+ return { data: currentHash, error: undefined };
488
+ }
489
+ const result = await action(mockDispatch, () => ({}), undefined);
490
+ return result;
491
+ }
492
+ const actionType = (action as any)?.type || "";
493
+ if (actionType.includes("upsertQueryEntries") || actionType.includes("upsert")) {
494
+ upsertActions.push(action);
495
+ }
496
+ return action;
497
+ });
498
+
499
+ const persistedData: PersistedCAL = {
500
+ version: PERSISTENCE_VERSION,
501
+ tokens: [
502
+ {
503
+ data: toTokenCurrencyRaw(mockToken),
504
+ timestamp: now,
505
+ },
506
+ ],
507
+ hashes: { ethereum: storedHash },
508
+ };
509
+
510
+ await restoreTokensToCache(mockDispatch, persistedData, ttl);
511
+
512
+ expect(upsertActions).toHaveLength(0);
513
+ });
514
+
515
+ it("should skip restore when hash fetch fails", async () => {
516
+ const mockDispatch = jest.fn();
517
+ const now = Date.now();
518
+ const ttl = 24 * 60 * 60 * 1000;
519
+ const storedHash = "hash123";
520
+ const upsertActions: any[] = [];
521
+
522
+ mockDispatch.mockImplementation(async action => {
523
+ if (typeof action === "function") {
524
+ const actionStr = String(action);
525
+ if (actionStr.includes("getTokensSyncHash")) {
526
+ throw new Error("Network error");
527
+ }
528
+ const result = await action(mockDispatch, () => ({}), undefined);
529
+ return result;
530
+ }
531
+ const actionType = (action as any)?.type || "";
532
+ if (actionType.includes("upsertQueryEntries") || actionType.includes("upsert")) {
533
+ upsertActions.push(action);
534
+ }
535
+ return action;
536
+ });
537
+
538
+ const persistedData: PersistedCAL = {
539
+ version: PERSISTENCE_VERSION,
540
+ tokens: [
541
+ {
542
+ data: toTokenCurrencyRaw(mockToken),
543
+ timestamp: now,
544
+ },
545
+ ],
546
+ hashes: { ethereum: storedHash },
547
+ };
548
+
549
+ await restoreTokensToCache(mockDispatch, persistedData, ttl);
550
+
551
+ expect(upsertActions).toHaveLength(0);
552
+ });
553
+
554
+ it("should restore tokens when currentHash is undefined (cannot validate)", async () => {
555
+ const mockDispatch = jest.fn();
556
+ const now = Date.now();
557
+ const ttl = 24 * 60 * 60 * 1000;
558
+ const storedHash = "hash123";
559
+ const upsertActions: any[] = [];
560
+
561
+ mockDispatch.mockImplementation(async action => {
562
+ if (typeof action === "function") {
563
+ const actionStr = String(action);
564
+ if (actionStr.includes("getTokensSyncHash")) {
565
+ return { data: undefined, error: undefined };
566
+ }
567
+ if (actionStr.includes("upsertQueryEntries") || actionStr.includes("upsert")) {
568
+ upsertActions.push(action);
569
+ }
570
+ const result = await action(mockDispatch, () => ({}), undefined);
571
+ return result;
572
+ }
573
+ const actionType = (action as any)?.type || "";
574
+ if (actionType.includes("upsertQueryEntries") || actionType.includes("upsert")) {
575
+ upsertActions.push(action);
576
+ }
577
+ return action;
578
+ });
579
+
580
+ const persistedData: PersistedCAL = {
581
+ version: PERSISTENCE_VERSION,
582
+ tokens: [
583
+ {
584
+ data: toTokenCurrencyRaw(mockToken),
585
+ timestamp: now,
586
+ },
587
+ ],
588
+ hashes: { ethereum: storedHash },
589
+ };
590
+
591
+ await restoreTokensToCache(mockDispatch, persistedData, ttl);
592
+
593
+ expect(upsertActions.length).toBeGreaterThan(0);
594
+ });
595
+
596
+ it("should restore tokens without hash (backward compatibility)", async () => {
597
+ const mockDispatch = jest.fn();
598
+ const now = Date.now();
599
+ const ttl = 24 * 60 * 60 * 1000;
600
+
601
+ const persistedData: PersistedCAL = {
602
+ version: PERSISTENCE_VERSION,
603
+ tokens: [
604
+ {
605
+ data: toTokenCurrencyRaw(mockToken),
606
+ timestamp: now,
607
+ },
608
+ ],
609
+ // No hashes field - old persisted data
610
+ };
611
+
612
+ await restoreTokensToCache(mockDispatch, persistedData, ttl);
613
+
614
+ const invalidateCalls = mockDispatch.mock.calls.filter(call => {
615
+ const action = call[0];
616
+ if (!action) return false;
617
+ const callStr = String(action);
618
+ return (
619
+ callStr.includes("invalidateTags") || (action as any)?.type?.includes("invalidateTags")
620
+ );
621
+ });
622
+ expect(invalidateCalls).toHaveLength(0);
623
+ expect(mockDispatch).toHaveBeenCalled();
624
+ });
625
+
626
+ it("should handle multiple currencies with different hash states", async () => {
627
+ const mockDispatch = jest.fn();
628
+ const now = Date.now();
629
+ const ttl = 24 * 60 * 60 * 1000;
630
+ let getHashCallCount = 0;
631
+ const upsertActions: any[] = [];
632
+
633
+ const polygonToken: TokenCurrency = {
634
+ ...mockToken,
635
+ id: "polygon/erc20/usdt",
636
+ parentCurrency: findCryptoCurrencyById("polygon")!,
637
+ };
638
+
639
+ mockDispatch.mockImplementation(async action => {
640
+ if (typeof action === "function") {
641
+ const actionStr = String(action);
642
+ if (actionStr.includes("getTokensSyncHash")) {
643
+ getHashCallCount++;
644
+ if (getHashCallCount === 2) {
645
+ return { data: "hash999", error: undefined };
646
+ }
647
+ return { data: "hash123", error: undefined };
648
+ }
649
+ if (actionStr.includes("upsertQueryEntries") || actionStr.includes("upsert")) {
650
+ upsertActions.push(action);
651
+ }
652
+ const result = await action(mockDispatch, () => ({}), undefined);
653
+ return result;
654
+ }
655
+ const actionType = (action as any)?.type || "";
656
+ if (actionType.includes("upsertQueryEntries") || actionType.includes("upsert")) {
657
+ upsertActions.push(action);
658
+ }
659
+ return action;
660
+ });
661
+
662
+ const persistedData: PersistedCAL = {
663
+ version: PERSISTENCE_VERSION,
664
+ tokens: [
665
+ {
666
+ data: toTokenCurrencyRaw(mockToken),
667
+ timestamp: now,
668
+ },
669
+ {
670
+ data: toTokenCurrencyRaw(polygonToken),
671
+ timestamp: now,
672
+ },
673
+ ],
674
+ hashes: {
675
+ ethereum: "hash123",
676
+ polygon: "hash456",
677
+ },
678
+ };
679
+
680
+ await restoreTokensToCache(mockDispatch, persistedData, ttl);
681
+
682
+ expect(upsertActions.length).toBeGreaterThan(0);
683
+ });
684
+ });
685
+
686
+ describe("extractHashesFromState", () => {
687
+ it("should extract hashes from getTokensSyncHash queries", () => {
688
+ const mockState = {
689
+ [cryptoAssetsApi.reducerPath]: {
690
+ queries: {
691
+ 'getTokensSyncHash("ethereum")': {
692
+ status: "fulfilled",
693
+ data: "hash123",
694
+ endpointName: "getTokensSyncHash",
695
+ },
696
+ 'getTokensSyncHash("polygon")': {
697
+ status: "fulfilled",
698
+ data: "hash456",
699
+ endpointName: "getTokensSyncHash",
700
+ },
701
+ },
702
+ },
703
+ } as unknown as StateWithCryptoAssets;
704
+
705
+ const hashes = extractHashesFromState(mockState);
706
+
707
+ expect(hashes).toEqual({
708
+ ethereum: "hash123",
709
+ polygon: "hash456",
710
+ });
711
+ });
712
+
713
+ it("should return empty object when no hash queries exist", () => {
714
+ const mockState = {
715
+ [cryptoAssetsApi.reducerPath]: {
716
+ queries: {
717
+ 'findTokenById({"id":"ethereum/erc20/usdt"})': {
718
+ status: "fulfilled",
719
+ data: mockToken,
720
+ endpointName: "findTokenById",
721
+ },
722
+ },
723
+ },
724
+ } as unknown as StateWithCryptoAssets;
725
+
726
+ const hashes = extractHashesFromState(mockState);
727
+
728
+ expect(hashes).toEqual({});
729
+ });
730
+
731
+ it("should return empty object if no RTK Query state", () => {
732
+ const mockState = {} as unknown as StateWithCryptoAssets;
733
+
734
+ const hashes = extractHashesFromState(mockState);
735
+
736
+ expect(hashes).toEqual({});
737
+ });
738
+
739
+ it("should return empty object if queries is undefined", () => {
740
+ const mockState = {
741
+ [cryptoAssetsApi.reducerPath]: {},
742
+ } as unknown as StateWithCryptoAssets;
743
+
744
+ const hashes = extractHashesFromState(mockState);
745
+
746
+ expect(hashes).toEqual({});
747
+ });
748
+ });
749
+
750
+ describe("extractPersistedCALFromState", () => {
751
+ it("should extract complete PersistedCAL with tokens and hashes", () => {
752
+ const mockState = {
753
+ [cryptoAssetsApi.reducerPath]: {
754
+ queries: {
755
+ 'findTokenById({"id":"ethereum/erc20/usdt"})': {
756
+ status: "fulfilled",
757
+ data: mockToken,
758
+ endpointName: "findTokenById",
759
+ fulfilledTimeStamp: Date.now(),
760
+ },
761
+ 'getTokensSyncHash("ethereum")': {
762
+ status: "fulfilled",
763
+ data: "hash123",
764
+ endpointName: "getTokensSyncHash",
765
+ },
766
+ },
767
+ },
768
+ } as unknown as StateWithCryptoAssets;
769
+
770
+ const persistedData = extractPersistedCALFromState(mockState);
771
+
772
+ expect(persistedData.version).toBe(PERSISTENCE_VERSION);
773
+ expect(persistedData.tokens).toHaveLength(1);
774
+ expect(persistedData.tokens[0].data.id).toBe("ethereum/erc20/usdt");
775
+ expect(persistedData.hashes).toEqual({ ethereum: "hash123" });
776
+ });
777
+
778
+ it("should extract PersistedCAL without hashes if none exist", () => {
779
+ const mockState = {
780
+ [cryptoAssetsApi.reducerPath]: {
781
+ queries: {
782
+ 'findTokenById({"id":"ethereum/erc20/usdt"})': {
783
+ status: "fulfilled",
784
+ data: mockToken,
785
+ endpointName: "findTokenById",
786
+ fulfilledTimeStamp: Date.now(),
787
+ },
788
+ },
789
+ },
790
+ } as unknown as StateWithCryptoAssets;
791
+
792
+ const persistedData = extractPersistedCALFromState(mockState);
793
+
794
+ expect(persistedData.version).toBe(PERSISTENCE_VERSION);
795
+ expect(persistedData.tokens).toHaveLength(1);
796
+ expect(persistedData.hashes).toBeUndefined();
797
+ });
798
+
799
+ it("should return empty tokens array if no tokens found", () => {
800
+ const mockState = {
801
+ [cryptoAssetsApi.reducerPath]: {
802
+ queries: {
803
+ 'getTokensSyncHash("ethereum")': {
804
+ status: "fulfilled",
805
+ data: "hash123",
806
+ endpointName: "getTokensSyncHash",
807
+ },
808
+ },
809
+ },
810
+ } as unknown as StateWithCryptoAssets;
811
+
812
+ const persistedData = extractPersistedCALFromState(mockState);
813
+
814
+ expect(persistedData.version).toBe(PERSISTENCE_VERSION);
815
+ expect(persistedData.tokens).toHaveLength(0);
816
+ expect(persistedData.hashes).toEqual({ ethereum: "hash123" });
817
+ });
360
818
  });
361
819
  });