diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 1250d59a6..62d85fc09 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -115,11 +115,6 @@ "count": 1 } }, - "packages/bitcoin-wallet-snap/src/store/InMemoryCache.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, "packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts": { "@typescript-eslint/explicit-function-return-type": { "count": 1 @@ -149,19 +144,6 @@ "count": 2 } }, - "packages/solana-wallet-snap/src/core/caching/useCache.test.ts": { - "@typescript-eslint/explicit-function-return-type": { - "count": 4 - } - }, - "packages/solana-wallet-snap/src/core/caching/useCache.ts": { - "@typescript-eslint/explicit-function-return-type": { - "count": 1 - }, - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, "packages/solana-wallet-snap/src/core/clients/nft-api/NftApiClient.ts": { "@typescript-eslint/explicit-function-return-type": { "count": 5 @@ -187,14 +169,6 @@ "count": 5 } }, - "packages/solana-wallet-snap/src/core/components/ActionHeader/ActionHeader.tsx": { - "@typescript-eslint/explicit-function-return-type": { - "count": 1 - }, - "@typescript-eslint/no-shadow": { - "count": 1 - } - }, "packages/solana-wallet-snap/src/core/fees/FeeCalculator.test.ts": { "@typescript-eslint/no-explicit-any": { "count": 1 @@ -352,10 +326,7 @@ }, "packages/solana-wallet-snap/src/core/services/connection/SolanaConnection.ts": { "@typescript-eslint/explicit-function-return-type": { - "count": 2 - }, - "@typescript-eslint/no-explicit-any": { - "count": 2 + "count": 1 } }, "packages/solana-wallet-snap/src/core/services/connection/transport/createToggleInfuraBigtableLookupsTransports.ts": { @@ -408,22 +379,6 @@ "count": 1 } }, - "packages/solana-wallet-snap/src/core/services/nft/NftService.test.ts": { - "@typescript-eslint/no-unused-vars": { - "count": 1 - }, - "jest/no-disabled-tests": { - "count": 1 - } - }, - "packages/solana-wallet-snap/src/core/services/nft/NftService.ts": { - "@typescript-eslint/no-unused-vars": { - "count": 1 - }, - "no-unused-private-class-members": { - "count": 2 - } - }, "packages/solana-wallet-snap/src/core/services/send/ISendTransactionBuilder.ts": { "import-x/no-named-as-default": { "count": 1 @@ -575,17 +530,6 @@ "count": 1 } }, - "packages/solana-wallet-snap/src/core/services/token-prices/TokenPrices.ts": { - "@typescript-eslint/explicit-function-return-type": { - "count": 1 - }, - "@typescript-eslint/prefer-nullish-coalescing": { - "count": 2 - }, - "import-x/no-named-as-default": { - "count": 1 - } - }, "packages/solana-wallet-snap/src/core/services/transaction-scan/TransactionScan.ts": { "no-restricted-syntax": { "count": 5 @@ -641,11 +585,6 @@ "count": 1 } }, - "packages/solana-wallet-snap/src/core/test/mocks/market-data.ts": { - "@typescript-eslint/explicit-function-return-type": { - "count": 1 - } - }, "packages/solana-wallet-snap/src/core/test/mocks/startMockSolanaRpc.test.ts": { "@typescript-eslint/explicit-function-return-type": { "count": 1 @@ -673,41 +612,6 @@ "count": 1 } }, - "packages/solana-wallet-snap/src/core/test/mocks/transactions-data/address-1/transaction-2.ts": { - "@typescript-eslint/no-unused-vars": { - "count": 1 - } - }, - "packages/solana-wallet-snap/src/core/test/mocks/transactions-data/address-1/transaction-3.ts": { - "@typescript-eslint/no-unused-vars": { - "count": 1 - } - }, - "packages/solana-wallet-snap/src/core/test/mocks/transactions-data/address-1/transaction-4.ts": { - "@typescript-eslint/no-unused-vars": { - "count": 1 - } - }, - "packages/solana-wallet-snap/src/core/test/mocks/transactions-data/address-2/transaction-1.ts": { - "@typescript-eslint/no-unused-vars": { - "count": 1 - } - }, - "packages/solana-wallet-snap/src/core/test/mocks/transactions-data/address-2/transaction-2.ts": { - "@typescript-eslint/no-unused-vars": { - "count": 1 - } - }, - "packages/solana-wallet-snap/src/core/test/mocks/transactions-data/address-2/transaction-3.ts": { - "@typescript-eslint/no-unused-vars": { - "count": 1 - } - }, - "packages/solana-wallet-snap/src/core/test/mocks/transactions-data/address-2/transaction-4.ts": { - "@typescript-eslint/no-unused-vars": { - "count": 1 - } - }, "packages/solana-wallet-snap/src/core/test/mocks/transactions-data/native-sol-transfer-to-self.ts": { "@typescript-eslint/no-unused-vars": { "count": 1 @@ -748,11 +652,6 @@ "count": 1 } }, - "packages/solana-wallet-snap/src/core/test/mocks/transactions-data/swap-failed-transaction.ts": { - "@typescript-eslint/no-unused-vars": { - "count": 2 - } - }, "packages/solana-wallet-snap/src/core/test/mocks/transactions-data/swap-sol-to-obric.ts": { "@typescript-eslint/no-unused-vars": { "count": 1 @@ -778,11 +677,6 @@ "count": 1 } }, - "packages/solana-wallet-snap/src/core/types/form.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, "packages/solana-wallet-snap/src/core/types/solana.ts": { "@typescript-eslint/no-explicit-any": { "count": 1 @@ -796,22 +690,6 @@ "count": 1 } }, - "packages/solana-wallet-snap/src/core/utils/concurrency.test.ts": { - "promise/always-return": { - "count": 1 - } - }, - "packages/solana-wallet-snap/src/core/utils/concurrency.ts": { - "@typescript-eslint/explicit-function-return-type": { - "count": 1 - }, - "@typescript-eslint/no-explicit-any": { - "count": 6 - }, - "promise/param-names": { - "count": 1 - } - }, "packages/solana-wallet-snap/src/core/utils/conversion.test.ts": { "import-x/no-named-as-default": { "count": 1 @@ -835,14 +713,6 @@ "count": 1 } }, - "packages/solana-wallet-snap/src/core/utils/diffObjects.ts": { - "@typescript-eslint/explicit-function-return-type": { - "count": 1 - }, - "@typescript-eslint/no-explicit-any": { - "count": 7 - } - }, "packages/solana-wallet-snap/src/core/utils/errors.test.ts": { "@typescript-eslint/explicit-function-return-type": { "count": 1 @@ -885,19 +755,6 @@ "count": 1 } }, - "packages/solana-wallet-snap/src/core/utils/formatFiatBalance.test.ts": { - "import-x/no-named-as-default": { - "count": 1 - } - }, - "packages/solana-wallet-snap/src/core/utils/formatFiatBalance.ts": { - "@typescript-eslint/explicit-function-return-type": { - "count": 1 - }, - "import-x/no-named-as-default": { - "count": 1 - } - }, "packages/solana-wallet-snap/src/core/utils/fromTokenUnit.ts": { "import-x/no-named-as-default": { "count": 1 @@ -908,16 +765,6 @@ "count": 1 } }, - "packages/solana-wallet-snap/src/core/utils/getAccountIdFromAddress.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "packages/solana-wallet-snap/src/core/utils/getClusterFromScope.ts": { - "@typescript-eslint/explicit-function-return-type": { - "count": 1 - } - }, "packages/solana-wallet-snap/src/core/utils/getNetworkFromToken.ts": { "@typescript-eslint/explicit-function-return-type": { "count": 1 @@ -959,16 +806,6 @@ "count": 10 } }, - "packages/solana-wallet-snap/src/core/utils/toTokenUnit.test.ts": { - "import-x/no-named-as-default": { - "count": 1 - } - }, - "packages/solana-wallet-snap/src/core/utils/toTokenUnit.ts": { - "import-x/no-named-as-default": { - "count": 1 - } - }, "packages/solana-wallet-snap/src/core/utils/tokenToFiat.ts": { "@typescript-eslint/explicit-function-return-type": { "count": 1 diff --git a/packages/snap-networks-utils/CHANGELOG.md b/packages/snap-networks-utils/CHANGELOG.md index 57c5a84b9..141915da2 100644 --- a/packages/snap-networks-utils/CHANGELOG.md +++ b/packages/snap-networks-utils/CHANGELOG.md @@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add shared caching utilities for network snaps ([#287](https://github.com/MetaMask/internal-snaps/pull/287)) + - `ICache`, `CacheEntry`, and `TimestampMilliseconds` for describing a generic cache + - `InMemoryCache`, a TTL-backed in-memory cache + - `StateCache`, a cache backed by a snap state manager + - `useCache` and `useCacheUntil` for wrapping functions with fixed-TTL and dynamic-expiry caching - Add shared proof-of-ownership message parsing utilities, batch request/response structs, and batch request/response types. ([#268](https://github.com/MetaMask/internal-snaps/pull/268)) - Add a `UuidStruct` Superstruct for validating UUID v4 strings. ([#243](https://github.com/MetaMask/internal-snaps/pull/243)) - Add helpers `serialize`, `deserialize`, and `Serializable` for round-tripping `BigNumber`, `bigint`, `Uint8Array`, and `undefined` through snap state ([#197](https://github.com/MetaMask/internal-snaps/pull/197)) diff --git a/packages/snap-networks-utils/src/index.ts b/packages/snap-networks-utils/src/index.ts index 77ed424d5..6b8fac84c 100644 --- a/packages/snap-networks-utils/src/index.ts +++ b/packages/snap-networks-utils/src/index.ts @@ -70,6 +70,26 @@ export { normalizeError, } from './utils/errors'; export { InFlightCoalescer } from './utils/dedupe/InFlightCoalescer'; +export { InMemoryCache } from './utils/cache/InMemoryCache'; +export { StateCache } from './utils/cache/StateCache'; +export { useCache } from './utils/cache/useCache'; +export { useCacheUntil } from './utils/cache/useCacheUntil'; +export type { CacheOptions } from './utils/cache/useCache'; +export type { + CacheUntilOptions, + ResultWithExpiry, +} from './utils/cache/useCacheUntil'; +export type { + ICache, + CacheEntry, + TimestampMilliseconds, +} from './utils/cache/types'; +export type { + CacheStateManager, + CacheStore, + CachePrefix, + StateValue, +} from './utils/cache/StateCache'; export type { CreateSnapErrorHandlingOptions, CreateTrackErrorOptions, diff --git a/packages/snap-networks-utils/src/utils/cache/InMemoryCache.test.ts b/packages/snap-networks-utils/src/utils/cache/InMemoryCache.test.ts new file mode 100644 index 000000000..563c6ec3b --- /dev/null +++ b/packages/snap-networks-utils/src/utils/cache/InMemoryCache.test.ts @@ -0,0 +1,449 @@ +import { Logger, LogLevel } from '../logger/Logger'; +import { InMemoryCache } from './InMemoryCache'; + +describe('InMemoryCache', () => { + let logger: Logger; + + const JAN_1_2024 = 1704067200000; + + beforeEach(() => { + logger = new Logger({ level: LogLevel.SILENT }); + }); + + describe('get', () => { + it('returns the cached value if present and not expired', async () => { + const cache = new InMemoryCache(logger); + await cache.set('key', 'value'); + + expect(await cache.get('key')).toBe('value'); + }); + + it('returns undefined if the key is not present', async () => { + const cache = new InMemoryCache(logger); + + expect(await cache.get('key')).toBeUndefined(); + }); + + it('returns undefined and removes the entry if it is expired', async () => { + const cache = new InMemoryCache(logger); + const mockDateNow = jest + .spyOn(Date, 'now') + .mockReturnValueOnce(JAN_1_2024); + + await cache.set('key', 'value', 1000); + mockDateNow.mockReturnValue(JAN_1_2024 + 1001); + + expect(await cache.get('key')).toBeUndefined(); + expect(await cache.size()).toBe(0); + + mockDateNow.mockRestore(); + }); + }); + + describe('set', () => { + it('stores the value with the default ttl if none is provided', async () => { + const cache = new InMemoryCache(logger); + const mockDateNow = jest.spyOn(Date, 'now').mockReturnValue(JAN_1_2024); + + await cache.set('key', 'value'); + + expect(await cache.peek('key')).toBe('value'); + expect(await cache.keys()).toHaveLength(1); + + mockDateNow.mockRestore(); + }); + + it('stores the value with the provided ttl', async () => { + const cache = new InMemoryCache(logger); + const mockDateNow = jest + .spyOn(Date, 'now') + .mockReturnValueOnce(JAN_1_2024); + + await cache.set('key', 'value', 1000); + + mockDateNow.mockReturnValue(JAN_1_2024 + 999); + expect(await cache.get('key')).toBe('value'); + + mockDateNow.mockReturnValue(JAN_1_2024 + 1000); + expect(await cache.get('key')).toBe('value'); + + mockDateNow.mockReturnValue(JAN_1_2024 + 1001); + expect(await cache.get('key')).toBeUndefined(); + + mockDateNow.mockRestore(); + }); + + it('clamps the expiry to the maximum safe integer', async () => { + const cache = new InMemoryCache(logger); + + await cache.set('key', 'value', Number.MAX_SAFE_INTEGER); + + expect(await cache.get('key')).toBe('value'); + }); + + it('supports a ttl of 0', async () => { + const cache = new InMemoryCache(logger); + const mockDateNow = jest.spyOn(Date, 'now').mockReturnValue(JAN_1_2024); + + await cache.set('key', 'value', 0); + + expect(await cache.get('key')).toBe('value'); + + mockDateNow.mockReturnValue(JAN_1_2024 + 1); + expect(await cache.get('key')).toBeUndefined(); + + mockDateNow.mockRestore(); + }); + + it('throws an error if the ttl is not a number', async () => { + const cache = new InMemoryCache(logger); + + await expect( + cache.set('key', 'value', 'not a number' as unknown as number), + ).rejects.toThrow('TTL must be a number'); + }); + + it('throws an error if the ttl is negative', async () => { + const cache = new InMemoryCache(logger); + + await expect(cache.set('key', 'value', -1)).rejects.toThrow( + 'TTL must be positive', + ); + }); + + it('throws an error if the ttl is too large', async () => { + const cache = new InMemoryCache(logger); + + await expect( + cache.set('key', 'value', Number.MAX_SAFE_INTEGER + 1), + ).rejects.toThrow('TTL must be less than 2^53 - 1'); + }); + }); + + describe('delete', () => { + it('returns true if the key was present', async () => { + const cache = new InMemoryCache(logger); + await cache.set('key', 'value'); + + expect(await cache.delete('key')).toBe(true); + expect(await cache.get('key')).toBeUndefined(); + }); + + it('returns false if the key was not present', async () => { + const cache = new InMemoryCache(logger); + + expect(await cache.delete('key')).toBe(false); + }); + + it('returns false if the mdelete result does not include the key', async () => { + const cache = new InMemoryCache(logger); + await cache.set('key', 'value'); + jest.spyOn(cache, 'mdelete').mockResolvedValue({}); + + expect(await cache.delete('key')).toBe(false); + }); + }); + + describe('clear', () => { + it('removes all entries', async () => { + const cache = new InMemoryCache(logger); + await cache.set('key', 'value'); + await cache.set('otherKey', 'otherValue'); + + await cache.clear(); + + expect(await cache.size()).toBe(0); + expect(await cache.get('key')).toBeUndefined(); + expect(await cache.get('otherKey')).toBeUndefined(); + }); + }); + + describe('has', () => { + it('returns true if the key is present and not expired', async () => { + const cache = new InMemoryCache(logger); + await cache.set('key', 'value'); + + expect(await cache.has('key')).toBe(true); + }); + + it('returns false if the key is not present', async () => { + const cache = new InMemoryCache(logger); + + expect(await cache.has('key')).toBe(false); + }); + + it('returns false and removes the entry if it is expired', async () => { + const cache = new InMemoryCache(logger); + const mockDateNow = jest + .spyOn(Date, 'now') + .mockReturnValueOnce(JAN_1_2024); + + await cache.set('key', 'value', 1000); + mockDateNow.mockReturnValue(JAN_1_2024 + 1001); + + expect(await cache.has('key')).toBe(false); + expect(await cache.size()).toBe(0); + + mockDateNow.mockRestore(); + }); + }); + + describe('keys', () => { + it('returns all keys in the cache', async () => { + const cache = new InMemoryCache(logger); + await cache.set('key', 'value'); + await cache.set('otherKey', 'otherValue'); + + expect(await cache.keys()).toStrictEqual(['key', 'otherKey']); + }); + + it('removes expired entries before returning the keys', async () => { + const cache = new InMemoryCache(logger); + const mockDateNow = jest + .spyOn(Date, 'now') + .mockReturnValueOnce(JAN_1_2024) + .mockReturnValueOnce(JAN_1_2024); + + await cache.set('key', 'value', 1000); + await cache.set('otherKey', 'otherValue', 5000); + + mockDateNow.mockReturnValue(JAN_1_2024 + 1001); + + expect(await cache.keys()).toStrictEqual(['otherKey']); + + mockDateNow.mockRestore(); + }); + + it('returns an empty array if the cache is empty', async () => { + const cache = new InMemoryCache(logger); + + expect(await cache.keys()).toStrictEqual([]); + }); + }); + + describe('size', () => { + it('returns the number of items in the cache', async () => { + const cache = new InMemoryCache(logger); + await cache.set('key', 'value'); + await cache.set('otherKey', 'otherValue'); + + expect(await cache.size()).toBe(2); + }); + + it('removes expired entries before returning the size', async () => { + const cache = new InMemoryCache(logger); + const mockDateNow = jest + .spyOn(Date, 'now') + .mockReturnValueOnce(JAN_1_2024) + .mockReturnValueOnce(JAN_1_2024); + + await cache.set('key', 'value', 1000); + await cache.set('otherKey', 'otherValue', 5000); + + mockDateNow.mockReturnValue(JAN_1_2024 + 1001); + + expect(await cache.size()).toBe(1); + + mockDateNow.mockRestore(); + }); + + it('returns 0 if the cache is empty', async () => { + const cache = new InMemoryCache(logger); + + expect(await cache.size()).toBe(0); + }); + }); + + describe('peek', () => { + it('returns the value without removing the entry', async () => { + const cache = new InMemoryCache(logger); + await cache.set('key', 'value'); + + expect(await cache.peek('key')).toBe('value'); + expect(await cache.size()).toBe(1); + }); + + it('returns undefined if the key is not present', async () => { + const cache = new InMemoryCache(logger); + + expect(await cache.peek('key')).toBeUndefined(); + }); + + it('returns undefined and removes the entry if it is expired', async () => { + const cache = new InMemoryCache(logger); + const mockDateNow = jest + .spyOn(Date, 'now') + .mockReturnValueOnce(JAN_1_2024); + + await cache.set('key', 'value', 1000); + mockDateNow.mockReturnValue(JAN_1_2024 + 1001); + + expect(await cache.peek('key')).toBeUndefined(); + expect(await cache.size()).toBe(0); + + mockDateNow.mockRestore(); + }); + }); + + describe('mget', () => { + it('returns the values for the given keys', async () => { + const cache = new InMemoryCache(logger); + await cache.set('key', 'value'); + await cache.set('otherKey', 'otherValue'); + + expect(await cache.mget(['key', 'otherKey'])).toStrictEqual({ + key: 'value', + otherKey: 'otherValue', + }); + }); + + it('returns undefined for keys that are not present', async () => { + const cache = new InMemoryCache(logger); + await cache.set('key', 'value'); + + expect(await cache.mget(['key', 'otherKey'])).toStrictEqual({ + key: 'value', + otherKey: undefined, + }); + }); + + it('removes expired entries before reading', async () => { + const cache = new InMemoryCache(logger); + const mockDateNow = jest + .spyOn(Date, 'now') + .mockReturnValueOnce(JAN_1_2024); + + await cache.set('key', 'value', 1000); + mockDateNow.mockReturnValue(JAN_1_2024 + 1001); + + expect(await cache.mget(['key'])).toStrictEqual({ key: undefined }); + expect(await cache.size()).toBe(0); + + mockDateNow.mockRestore(); + }); + }); + + describe('mset', () => { + it('no-ops if no entries are provided', async () => { + const cache = new InMemoryCache(logger); + + await cache.mset([]); + + expect(await cache.size()).toBe(0); + }); + + it('defers to set if there is only one entry', async () => { + const cache = new InMemoryCache(logger); + const setSpy = jest.spyOn(cache, 'set'); + + await cache.mset([{ key: 'key', value: 'value', ttlMilliseconds: 1000 }]); + + expect(setSpy).toHaveBeenCalledWith('key', 'value', 1000); + expect(await cache.get('key')).toBe('value'); + }); + + it('defers to set with an undefined ttl if there is only one entry without ttl', async () => { + const cache = new InMemoryCache(logger); + const setSpy = jest.spyOn(cache, 'set'); + + await cache.mset([{ key: 'key', value: 'value' }]); + + expect(setSpy).toHaveBeenCalledWith('key', 'value', undefined); + expect(await cache.get('key')).toBe('value'); + }); + + it('stores multiple entries', async () => { + const cache = new InMemoryCache(logger); + + await cache.mset([ + { key: 'key', value: 'value' }, + { key: 'otherKey', value: 'otherValue' }, + ]); + + expect(await cache.mget(['key', 'otherKey'])).toStrictEqual({ + key: 'value', + otherKey: 'otherValue', + }); + }); + + it('does not store undefined values', async () => { + const cache = new InMemoryCache(logger); + + await cache.mset([ + { key: 'key', value: 'value' }, + { key: 'undefinedKey', value: undefined }, + ]); + + expect(await cache.mget(['key', 'undefinedKey'])).toStrictEqual({ + key: 'value', + undefinedKey: undefined, + }); + expect(await cache.size()).toBe(1); + }); + + it('stores null values', async () => { + const cache = new InMemoryCache(logger); + + await cache.mset([{ key: 'key', value: null }]); + + expect(await cache.mget(['key'])).toStrictEqual({ key: null }); + }); + + it('stores entries with the provided ttl', async () => { + const cache = new InMemoryCache(logger); + const mockDateNow = jest.spyOn(Date, 'now').mockReturnValue(JAN_1_2024); + + await cache.mset([ + { key: 'key', value: 'value', ttlMilliseconds: 1000 }, + { key: 'otherKey', value: 'otherValue' }, + ]); + + mockDateNow.mockReturnValue(JAN_1_2024 + 1001); + + expect(await cache.mget(['key', 'otherKey'])).toStrictEqual({ + key: undefined, + otherKey: 'otherValue', + }); + + mockDateNow.mockRestore(); + }); + + it('throws an error if any ttl is invalid', async () => { + const cache = new InMemoryCache(logger); + + await expect( + cache.mset([ + { key: 'key', value: 'value' }, + { + key: 'otherKey', + value: 'otherValue', + ttlMilliseconds: 'not a number' as unknown as number, + }, + ]), + ).rejects.toThrow('TTL must be a number'); + }); + }); + + describe('mdelete', () => { + it('deletes the given keys and reports which ones were removed', async () => { + const cache = new InMemoryCache(logger); + await cache.set('key', 'value'); + await cache.set('otherKey', 'otherValue'); + + const result = await cache.mdelete(['key', 'otherKey', 'missingKey']); + + expect(result).toStrictEqual({ + key: true, + otherKey: true, + missingKey: false, + }); + expect(await cache.size()).toBe(0); + }); + + it('returns an empty object if no keys are provided', async () => { + const cache = new InMemoryCache(logger); + + expect(await cache.mdelete([])).toStrictEqual({}); + }); + }); +}); diff --git a/packages/solana-wallet-snap/src/core/caching/InMemoryCache.ts b/packages/snap-networks-utils/src/utils/cache/InMemoryCache.ts similarity index 90% rename from packages/solana-wallet-snap/src/core/caching/InMemoryCache.ts rename to packages/snap-networks-utils/src/utils/cache/InMemoryCache.ts index 90750af2a..a4cf059e4 100644 --- a/packages/solana-wallet-snap/src/core/caching/InMemoryCache.ts +++ b/packages/snap-networks-utils/src/utils/cache/InMemoryCache.ts @@ -1,8 +1,8 @@ -import type { Logger, Serializable } from '@metamask/snap-networks-utils'; import { assert } from '@metamask/utils'; -import type { ICache } from './ICache'; -import type { CacheEntry } from './types'; +import type { Logger } from '../logger/Logger'; +import type { Serializable } from '../serialization/types'; +import type { CacheEntry, ICache } from './types'; /** * A simple in-memory cache implementation supporting TTL (Time To Live) functionality. @@ -13,10 +13,10 @@ import type { CacheEntry } from './types'; export class InMemoryCache implements ICache { readonly #cache: Map = new Map(); - public readonly logger: Logger; + readonly #logger: Logger; constructor(logger: Logger) { - this.logger = logger.withPrefix('[💾 InMemoryCache]'); + this.#logger = logger; } #validateTtlOrThrow(ttlMilliseconds?: number): void { @@ -66,7 +66,7 @@ export class InMemoryCache implements ICache { this.#cache.set(key, { value, expiresAt: Math.min( - Date.now() + (ttlMilliseconds ?? Number.MAX_SAFE_INTEGER), + Date.now() + ttlMilliseconds, Number.MAX_SAFE_INTEGER, ), }); @@ -129,12 +129,12 @@ export class InMemoryCache implements ICache { for (const key of keys) { const cacheEntry = this.#cache.get(key); if (!cacheEntry) { - this.logger.info(`❌ Cache miss for key "${key}"`); + this.#logger.info(`[InMemoryCache] ❌ Cache miss for key "${key}"`); result[key] = undefined; continue; } - this.logger.info(`🎉 Cache hit for key "${key}"`); + this.#logger.info(`[InMemoryCache] 🎉 Cache hit for key "${key}"`); result[key] = cacheEntry.value; } diff --git a/packages/snap-networks-utils/src/utils/cache/StateCache.test.ts b/packages/snap-networks-utils/src/utils/cache/StateCache.test.ts new file mode 100644 index 000000000..4c7884d51 --- /dev/null +++ b/packages/snap-networks-utils/src/utils/cache/StateCache.test.ts @@ -0,0 +1,880 @@ +/* eslint-disable jest/prefer-strict-equal */ + +import { get, set, unset } from 'lodash'; + +import { Logger, LogLevel } from '../logger/Logger'; +import type { Serializable } from '../serialization/types'; +import type { CacheStateManager } from './StateCache'; +import type { StateValue } from './StateCache'; +import { StateCache } from './StateCache'; + +/** + * A simple implementation of a state manager that relies on an in-memory state, + * used for testing purposes. + */ +class InMemoryState implements CacheStateManager { + #state: StateValue; + + constructor(initialState: StateValue) { + this.#state = initialState; + } + + async get(): Promise { + return this.#state; + } + + async getKey( + key: string, + ): Promise { + return get(this.#state, key) as TKey | undefined; + } + + async setKey(key: string, value: Serializable): Promise { + set(this.#state, key, value); // Use lodash to set the value using a json path + } + + async update( + callback: (state: StateValue) => StateValue, + ): Promise { + return (this.#state = callback(this.#state)); + } + + async deleteKey(key: string): Promise { + // Using lodash's unset to leverage the json path capabilities + unset(this.#state, key); + } +} + +describe('StateCache', () => { + let logger: Logger; + + const createStateCache = ( + state: CacheStateManager, + prefix?: `__cache__${string}`, + ): StateCache => new StateCache(state, logger, prefix); + + beforeEach(() => { + logger = new Logger({ level: LogLevel.SILENT }); + }); + + describe('constructor', () => { + it('uses the default prefix if not specified', () => { + const cache = new StateCache(new InMemoryState({}), logger); + + expect(cache.prefix).toBe('__cache__default'); + }); + + it('uses the specified prefix if provided', () => { + const cache = createStateCache( + new InMemoryState({}), + '__cache__my-prefix', + ); + + expect(cache.prefix).toBe('__cache__my-prefix'); + }); + }); + + describe('get', () => { + it('returns undefined if the cache is not initialized', async () => { + const stateWithNoCache = new InMemoryState({ + name: 'John', // State has some data that is not related to the cache + // __cache__default: {} // State has not been initialized with cached data + }); + const cache = createStateCache(stateWithNoCache); + + const value = await cache.get('someKey'); + + expect(value).toBeUndefined(); + }); + + it('returns undefined if the cache is initialized but the key is not present', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: 1704067200000, // January 1, 2024 + }, + }, + }); + const cache = createStateCache(stateWithCache); + + const value = await cache.get('someOtherKey'); + + expect(value).toBeUndefined(); + }); + + it('returns the cached value if the cache is initialized and the key is present and not expired', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, // Expires in a long time + }, + }, + }); + const cache = createStateCache(stateWithCache); + + const value = await cache.get('someKey'); + + expect(value).toBe('someValue'); + }); + + it('returns undefined if the cache is initialized and the key is present but expired', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: 1704067200000, // January 1, 2024 + }, + }, + }); + const cache = createStateCache(stateWithCache); + + const value = await cache.get('someKey'); + + expect(value).toBeUndefined(); + }); + + it('deletes expired cache entries upon retrieval', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: 1704067200000, // January 1, 2024 + }, + }, + }); + const cache = createStateCache(stateWithCache); + + await cache.get('someKey'); + const stateValue = await stateWithCache.get(); + + expect(stateValue).toStrictEqual({ + __cache__default: {}, + }); + }); + }); + + describe('set', () => { + it('initializes the cache if it is not initialized', async () => { + const stateWithCache = new InMemoryState({}); + const cache = createStateCache(stateWithCache); + + await cache.set('someKey', 'someValue'); + const stateValue = await stateWithCache.get(); + + expect(stateValue).toStrictEqual({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + }); + + it('sets the cache entry with no expiration if no ttl is provided', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: {}, + }); + const cache = createStateCache(stateWithCache); + + await cache.set('someKey', 'someValue'); + const stateValue = await stateWithCache.get(); + + const value = await cache.get('someKey'); + + expect(value).toBe('someValue'); + expect(stateValue).toStrictEqual({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + }); + + it('overwrites the cache entry if it is present', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + const cache = createStateCache(stateWithCache); + + await cache.set('someKey', 'someOtherValue'); + const stateValue = await stateWithCache.get(); + + expect(stateValue).toStrictEqual({ + __cache__default: { + someKey: { + value: 'someOtherValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + }); + + it('sets the cache entry with the provided ttl', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: {}, + }); + const cache = createStateCache(stateWithCache); + jest.spyOn(Date, 'now').mockReturnValueOnce(1704067200000); // January 1, 2024 + + await cache.set('someKey', 'someValue', 1000); + const stateValue = await stateWithCache.get(); + + expect(stateValue).toStrictEqual({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: 1704067201000, // January 1, 2024 + 1 second + }, + }, + }); + }); + + it('supports a ttl of 0', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: {}, + }); + const cache = createStateCache(stateWithCache); + const mockDateNow = jest + .spyOn(Date, 'now') + .mockReturnValue(1704067200000); // January 1, 2024 + + await cache.set('someKey', 'someValue', 0); + const stateValue = await stateWithCache.get(); + + expect(stateValue).toStrictEqual({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: 1704067200000, // January 1, 2024 (+ 0 seconds) + }, + }, + }); + + // Change the mock to return a time after the expiration + mockDateNow.mockReturnValue(1704067200001); // January 1, 2024 + 1 millisecond + + const value = await cache.get('someKey'); // Should expire immediately + expect(value).toBeUndefined(); + + mockDateNow.mockRestore(); + }); + + it('throws an error if the ttl is not a number', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: {}, + }); + const cache = createStateCache(stateWithCache); + + await expect( + cache.set('someKey', 'someValue', 'not a number' as unknown as number), + ).rejects.toThrow('TTL must be a number'); + }); + + it('throws an error if the ttl is negative', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: {}, + }); + const cache = createStateCache(stateWithCache); + + await expect(cache.set('someKey', 'someValue', -1)).rejects.toThrow( + 'TTL must be positive', + ); + }); + + it('throws an error if the ttl is too large', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: {}, + }); + const cache = createStateCache(stateWithCache); + + await expect( + cache.set('someKey', 'someValue', Number.MAX_SAFE_INTEGER + 1), + ).rejects.toThrow('TTL must be less than 2^53 - 1'); + }); + }); + + describe('delete', () => { + it('deletes the cache entry and returns true if the entry was present', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + const cache = createStateCache(stateWithCache); + + const result = await cache.delete('someKey'); + expect(result).toBe(true); + + const value = await cache.get('someKey'); + + expect(value).toBeUndefined(); + }); + + it('leaves the cache unchanged and returns false if the entry was not present', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + const cache = createStateCache(stateWithCache); + + const result = await cache.delete('someOtherKey'); // Try to + const someKeyValue = await cache.get('someKey'); + const someOtherKeyValue = await cache.get('someOtherKey'); + + expect(result).toBe(false); + expect(someKeyValue).toBe('someValue'); + expect(someOtherKeyValue).toBeUndefined(); + }); + + it('returns false if the mdelete result does not include the key', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + const cache = createStateCache(stateWithCache); + jest.spyOn(cache, 'mdelete').mockResolvedValue({}); + + expect(await cache.delete('someKey')).toBe(false); + }); + }); + + describe('clear', () => { + it('empties the cache', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + const cache = createStateCache(stateWithCache); + + await cache.clear(); + const stateValue = await stateWithCache.get(); + + expect(stateValue).toStrictEqual({ + __cache__default: {}, + }); + }); + + it('does not throw an error if the cache is not initialized', async () => { + const stateWithCache = new InMemoryState({}); + const cache = createStateCache(stateWithCache); + + await cache.clear(); + const stateValue = await stateWithCache.get(); + + expect(stateValue).toStrictEqual({ + __cache__default: {}, + }); + }); + }); + + describe('has', () => { + it('returns true if the key is present in the cache', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + const cache = createStateCache(stateWithCache); + + const result = await cache.has('someKey'); + + expect(result).toBe(true); + }); + + it('returns false if the key is not present in the cache', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + const cache = createStateCache(stateWithCache); + + const result = await cache.has('someOtherKey'); + expect(result).toBe(false); + }); + + it('does not throw an error if the cache is not initialized', async () => { + const stateWithCache = new InMemoryState({}); + const cache = createStateCache(stateWithCache); + + const result = await cache.has('someKey'); + expect(result).toBe(false); + }); + }); + + describe('keys', () => { + it('returns all keys in the cache', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + someOtherKey: { + value: 'someOtherValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + const cache = createStateCache(stateWithCache); + + const result = await cache.keys(); + + expect(result).toStrictEqual(['someKey', 'someOtherKey']); + }); + + it('returns an empty array if the cache is not initialized', async () => { + const stateWithCache = new InMemoryState({}); + const cache = createStateCache(stateWithCache); + + const result = await cache.keys(); + + expect(result).toStrictEqual([]); + }); + }); + + describe('size', () => { + it('returns the number of items in the cache', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + someOtherKey: { + value: 'someOtherValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + const cache = createStateCache(stateWithCache); + + const result = await cache.size(); + + expect(result).toBe(2); + }); + + it('returns 0 if the cache is not initialized', async () => { + const stateWithCache = new InMemoryState({}); + const cache = createStateCache(stateWithCache); + + const result = await cache.size(); + + expect(result).toBe(0); + }); + }); + + describe('peek', () => { + it('returns the value of an unexpired key if it is present in the cache', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + const cache = createStateCache(stateWithCache); + + const result = await cache.peek('someKey'); + + expect(result).toBe('someValue'); + }); + + it('returns the value of an expired key if it is present in the cache', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: 1704067200000, // January 1, 2024 + }, + }, + }); + const cache = createStateCache(stateWithCache); + + const result = await cache.peek('someKey'); + + expect(result).toBe('someValue'); + }); + + it('returns undefined if the key is not present in the cache', async () => { + const stateWithCache = new InMemoryState({}); + const cache = createStateCache(stateWithCache); + + const result = await cache.peek('someOtherKey'); + + expect(result).toBeUndefined(); + }); + + it('does not throw an error if the cache is not initialized', async () => { + const stateWithCache = new InMemoryState({}); + const cache = createStateCache(stateWithCache); + + const result = await cache.peek('someKey'); + expect(result).toBeUndefined(); + }); + }); + + describe('mget', () => { + it('returns the values of the keys if they are present in the cache', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + someOtherKey: { + value: 'someOtherValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + const cache = createStateCache(stateWithCache); + + const result = await cache.mget(['someKey', 'someOtherKey']); + + expect(result).toStrictEqual({ + someKey: 'someValue', + someOtherKey: 'someOtherValue', + }); + }); + + it('returns undefined for keys that are not present in the cache', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + const cache = createStateCache(stateWithCache); + + const result = await cache.mget(['someKey', 'someOtherKey']); + + expect(result).toEqual({ + someKey: 'someValue', + someOtherKey: undefined, + }); + }); + + it('returns undefined for keys that are expired', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: 1704067200000, // January 1, 2024 + }, + }, + }); + const cache = createStateCache(stateWithCache); + + // Mock Date.now to return a time after the expiration + const mockDateNow = jest + .spyOn(Date, 'now') + .mockReturnValue(1704067200001); // January 1, 2024 + 1 millisecond + + const result = await cache.mget(['someKey']); + + expect(result).toEqual({ + someKey: undefined, + }); + + mockDateNow.mockRestore(); + }); + + it('returns undefined for keys that map to undefined entries', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: undefined, + }, + } as unknown as StateValue); + const cache = createStateCache(stateWithCache); + + const result = await cache.mget(['someKey']); + + expect(result).toEqual({ + someKey: undefined, + }); + }); + + it('returns an empty object if the cache is not initialized', async () => { + const stateWithCache = new InMemoryState({}); + const cache = createStateCache(stateWithCache); + + const result = await cache.mget(['someKey', 'someOtherKey']); + + expect(result).toStrictEqual({}); + }); + + it('deletes expired cache entries upon retrieval', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: 1704067200000, // January 1, 2024 + }, + someOtherKey: { + value: 'someOtherValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + const cache = createStateCache(stateWithCache); + + // Mock Date.now to return a time after the expiration + const mockDateNow = jest + .spyOn(Date, 'now') + .mockReturnValue(1704067200001); // January 1, 2024 + 1 millisecond + + await cache.mget(['someKey']); + const stateValue = await stateWithCache.get(); + + expect(stateValue).toStrictEqual({ + __cache__default: { + someOtherKey: { + value: 'someOtherValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + + mockDateNow.mockRestore(); + }); + }); + + describe('mset', () => { + it('sets the values of the keys if they are present in the cache', async () => { + const stateWithCache = new InMemoryState({}); + const cache = createStateCache(stateWithCache); + + await cache.mset([ + { key: 'someKey', value: 'someValue' }, + { key: 'someOtherKey', value: 'someOtherValue' }, + ]); + + const result = await cache.mget(['someKey', 'someOtherKey']); + + expect(result).toStrictEqual({ + someKey: 'someValue', + someOtherKey: 'someOtherValue', + }); + }); + + it('does not store undefined values in the cache', async () => { + const stateWithCache = new InMemoryState({}); + const cache = createStateCache(stateWithCache); + + await cache.mset([ + { key: 'someKey', value: 'someValue' }, + { key: 'undefinedKey', value: undefined }, + ]); + + const result = await cache.mget(['someKey', 'undefinedKey']); + + expect(result).toEqual({ + someKey: 'someValue', + undefinedKey: undefined, + }); + + // Verify the undefined value was not stored in the cache + const stateValue = await stateWithCache.get(); + expect(stateValue).toStrictEqual({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + }); + + it('stores null values in the cache', async () => { + const stateWithCache = new InMemoryState({}); + const cache = createStateCache(stateWithCache); + + await cache.mset([{ key: 'someKey', value: null }]); + + const result = await cache.mget(['someKey']); + + expect(result).toStrictEqual({ + someKey: null, + }); + }); + + it('does not throw an error if the cache is not initialized', async () => { + const stateWithCache = new InMemoryState({}); + const cache = createStateCache(stateWithCache); + + await cache.mset([{ key: 'someKey', value: 'someValue' }]); + + const result = await cache.mget(['someKey']); + + expect(result).toStrictEqual({ + someKey: 'someValue', + }); + }); + + it('throws an error if the ttl is invalid', async () => { + const stateWithCache = new InMemoryState({}); + const cache = createStateCache(stateWithCache); + + await expect( + cache.mset([ + { + key: 'someKey', + value: 'someValue', + ttlMilliseconds: 'not a number' as unknown as number, + }, + ]), + ).rejects.toThrow('TTL must be a number'); + }); + + it('does not affect other keys in the cache', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey0: { + value: 'someValue0', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + someKey1: { + value: 'someValue1', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + const cache = createStateCache(stateWithCache); + + await cache.mset([ + { key: 'someKey0', value: 'someValue0Overwritten' }, + { key: 'someKey2', value: 'someValue2' }, + ]); + + const result = await cache.mget(['someKey0', 'someKey1', 'someKey2']); + + expect(result).toStrictEqual({ + someKey0: 'someValue0Overwritten', + someKey1: 'someValue1', + someKey2: 'someValue2', + }); + }); + + it('no-ops if no entries are provided', async () => { + const stateWithCache = new InMemoryState({}); + const cache = createStateCache(stateWithCache); + const updateSpy = jest.spyOn(stateWithCache, 'update'); + + await cache.mset([]); + + expect(updateSpy).not.toHaveBeenCalled(); + }); + + it('defers to set if there is only one entry', async () => { + const stateWithCache = new InMemoryState({}); + const cache = createStateCache(stateWithCache); + const setSpy = jest.spyOn(cache, 'set'); + + const singleEntry = { + key: 'someKey', + value: 'someValue', + ttlMilliseconds: 1000, + }; + await cache.mset([singleEntry]); + + expect(setSpy).toHaveBeenCalledWith( + singleEntry.key, + singleEntry.value, + singleEntry.ttlMilliseconds, + ); + }); + }); + + describe('mdelete', () => { + it('deletes the keys from the cache', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + someOtherKey: { + value: 'someOtherValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + const cache = createStateCache(stateWithCache); + + await cache.mdelete(['someKey', 'someOtherKey']); + + const result = await cache.mget(['someKey', 'someOtherKey']); + + expect(result).toEqual({ + someKey: undefined, + someOtherKey: undefined, + }); + }); + + it('returns an object where the values are true if the keys were deleted and false if they were not present', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + const cache = createStateCache(stateWithCache); + + const result = await cache.mdelete(['someKey', 'someOtherKey']); + + expect(result).toStrictEqual({ + someKey: true, + someOtherKey: false, + }); + }); + + it('does not throw an error if the cache is not initialized', async () => { + const stateWithCache = new InMemoryState({}); + const cache = createStateCache(stateWithCache); + + const result = await cache.mdelete(['someKey', 'someOtherKey']); + + expect(result).toStrictEqual({ + someKey: false, + someOtherKey: false, + }); + }); + }); +}); diff --git a/packages/snap-networks-utils/src/utils/cache/StateCache.ts b/packages/snap-networks-utils/src/utils/cache/StateCache.ts new file mode 100644 index 000000000..b3d6458c1 --- /dev/null +++ b/packages/snap-networks-utils/src/utils/cache/StateCache.ts @@ -0,0 +1,285 @@ +import { assert } from '@metamask/utils'; + +import type { Logger } from '../logger/Logger'; +import type { Serializable } from '../serialization/types'; +import type { CacheEntry, ICache } from './types'; + +/** + * The minimal subset of a state manager that {@link StateCache} relies on. + * + * Any implementation whose `getKey`, `setKey` and `update` methods match these signatures + * (such as the `IStateManager` implementations in the network snaps) satisfies this interface structurally. + */ +export type CacheStateManager< + TStateValue extends Record, +> = { + getKey(key: string): Promise; + setKey(key: string, value: Serializable): Promise; + update( + updaterFunction: (state: TStateValue) => TStateValue, + ): Promise; +}; + +/** + * The whole cache store. + */ +export type CacheStore = Record | undefined; + +/** + * A prefix for the cache "location" in the state. Enforced to start with `__cache__` to avoid collisions with other state values. + */ +export type CachePrefix = `__cache__${string}`; + +/** + * Describes the shape of the whole state inside which the cache is stored. + */ +export type StateValue = { + [x: string]: Serializable; +} & { + [K in CachePrefix]?: CacheStore; +}; + +/** + * A cache that wraps any implementation of a state manager to store the cache. + * + * It is intended to be used with the snap's `State` class, but can be used with any other implementation of the state manager interface. For instance it can be used with an in-memory state implementation for testing purposes. + * + * By default, it stores its data in the `__cache__default` property of the state, but you can specify any other prefix you want, provided it starts with `__cache__` to avoid collisions with other state values. + * This is useful if you want to have multiple independent caches in the same state. + * + * ``` + * { + * ..., // other state values + * __cache__default: { + * key1: value1, + * key2: value2, + * }, + * __cache__my-prefix: { + * key3: value3, + * key4: value4, + * }, + * } + * ``` + * + * @example + * ```ts + * const state = new State({}); // Here we use the real snap's state + * const rootLogger = new Logger({ level: LogLevel.INFO }); + * const cache = new StateCache(state, rootLogger, '__cache__my-prefix'); + * + * // state looks like this: + * // { + * // ..., // other state values + * // no __cache__my-prefix yet + * // } + * + * await cache.set('key1', 'value1'); + * + * // state looks like this: + * // { + * // ..., // other state values + * // __cache__my-prefix: { + * // key1: value1, + * // }, + * // } + * ``` + */ +export class StateCache implements ICache { + readonly #state: CacheStateManager; + + readonly #logger: Logger; + + public readonly prefix: CachePrefix; + + constructor( + state: CacheStateManager, + logger: Logger, + prefix: CachePrefix = '__cache__default', + ) { + this.#state = state; + this.#logger = logger; + this.prefix = prefix; + } + + async get(key: string): Promise { + const result = await this.mget([key]); + return result[key]; + } + + async set( + key: string, + value: Serializable, + ttlMilliseconds = Number.MAX_SAFE_INTEGER, + ): Promise { + this.#validateTtlOrThrow(ttlMilliseconds); + + await this.#state.setKey(`${this.prefix}.${key}`, { + value, + expiresAt: Math.min( + Date.now() + ttlMilliseconds, + Number.MAX_SAFE_INTEGER, + ), + }); + } + + #validateTtlOrThrow(ttlMilliseconds?: number): void { + if (ttlMilliseconds === undefined) { + return; + } + + if (typeof ttlMilliseconds !== 'number') { + throw new Error('TTL must be a number'); + } + + if (ttlMilliseconds < 0) { + throw new Error('TTL must be positive'); + } + + if (ttlMilliseconds > Number.MAX_SAFE_INTEGER) { + throw new Error('TTL must be less than 2^53 - 1'); + } + } + + async delete(key: string): Promise { + const result = await this.mdelete([key]); + return result[key] ?? false; + } + + async clear(): Promise { + await this.#state.setKey(this.prefix, {}); + } + + async has(key: string): Promise { + const result = await this.get(key); + return result !== undefined; + } + + async keys(): Promise { + const cacheStore = await this.#state.getKey(this.prefix); + + return Object.keys(cacheStore ?? {}); + } + + async size(): Promise { + const cacheStore = await this.#state.getKey(this.prefix); + + return Object.keys(cacheStore ?? {}).length; + } + + async peek(key: string): Promise { + const cacheStore = await this.#state.getKey(this.prefix); + const cacheEntry = cacheStore?.[key]; + + return cacheEntry?.value; + } + + async mget( + keys: string[], + ): Promise> { + const cacheStore = await this.#state.getKey(this.prefix); + + // If cache is not initialized, return empty object + if (!cacheStore) { + return {}; + } + + const keysAndValues = Object.entries(cacheStore).filter(([key]) => + keys.includes(key), + ); + + const expiredKeys = keysAndValues.filter( + ([_unused, cacheEntry]) => + cacheEntry && cacheEntry.expiresAt < Date.now(), + ); + + await this.mdelete(expiredKeys.map(([key]) => key)); + + const result: Record = {}; + + // First, handle keys that exist in the cache + keysAndValues.forEach(([key, cacheEntry]) => { + if (cacheEntry === undefined) { + this.#logger.info(`[StateCache] ❌ Cache miss for key "${key}"`); + result[key] = undefined; + return; + } + + if (cacheEntry.expiresAt < Date.now()) { + this.#logger.info(`[StateCache] ⌛ Cache expired for key "${key}"`); + result[key] = undefined; + } else { + this.#logger.info(`[StateCache] 🎉 Cache hit for key "${key}"`); + result[key] = cacheEntry.value; + } + }); + + // Then, handle keys that don't exist in the cache + keys.forEach((key) => { + if (!Object.hasOwn(result, key)) { + this.#logger.info(`[StateCache] ❌ Cache miss for key "${key}"`); + result[key] = undefined; + } + }); + + return result; + } + + async mset( + entries: { key: string; value: Serializable; ttlMilliseconds?: number }[], + ): Promise { + if (entries.length === 0) { + return; + } + + if (entries.length === 1) { + assert(entries[0]); // Enforce type narrowing as TS cannot infer that entries[0] is defined + const { key, value, ttlMilliseconds } = entries[0]; + await this.set(key, value, ttlMilliseconds); + return; + } + + entries.forEach(({ ttlMilliseconds }) => { + this.#validateTtlOrThrow(ttlMilliseconds); + }); + + // Using `state.update` is preferred for bulk `set`s, because it's more efficient and atomic. + await this.#state.update((stateValue) => { + const cacheStore = stateValue[this.prefix] ?? {}; + entries.forEach(({ key, value, ttlMilliseconds }) => { + if (value === undefined) { + return; + } + cacheStore[key] = { + value, + expiresAt: Math.min( + Date.now() + (ttlMilliseconds ?? Number.MAX_SAFE_INTEGER), + Number.MAX_SAFE_INTEGER, + ), + }; + }); + stateValue[this.prefix] = cacheStore; + return stateValue; + }); + } + + async mdelete(keys: string[]): Promise> { + const result: Record = {}; + + // Using `state.update` is preferred for bulk `delete`s, because it's more efficient and atomic. + await this.#state.update((stateValue) => { + const cacheStore = stateValue[this.prefix] ?? {}; + keys.forEach((key) => { + if (cacheStore[key] === undefined) { + result[key] = false; + } else { + delete cacheStore[key]; + result[key] = true; + } + }); + stateValue[this.prefix] = cacheStore; + return stateValue; + }); + + return result; + } +} diff --git a/packages/solana-wallet-snap/src/core/caching/ICache.ts b/packages/snap-networks-utils/src/utils/cache/types.ts similarity index 92% rename from packages/solana-wallet-snap/src/core/caching/ICache.ts rename to packages/snap-networks-utils/src/utils/cache/types.ts index 8a7a71f4c..82a3b7581 100644 --- a/packages/solana-wallet-snap/src/core/caching/ICache.ts +++ b/packages/snap-networks-utils/src/utils/cache/types.ts @@ -1,3 +1,15 @@ +import type { Serializable } from '../serialization/types'; + +export type TimestampMilliseconds = number; + +/** + * A single cache entry. + */ +export type CacheEntry = { + value: Serializable; + expiresAt: TimestampMilliseconds; +}; + /** * Interface for a generic cache implementation. * diff --git a/packages/solana-wallet-snap/src/core/caching/useCache.test.ts b/packages/snap-networks-utils/src/utils/cache/useCache.test.ts similarity index 80% rename from packages/solana-wallet-snap/src/core/caching/useCache.test.ts rename to packages/snap-networks-utils/src/utils/cache/useCache.test.ts index 0dd469654..13eee88c7 100644 --- a/packages/solana-wallet-snap/src/core/caching/useCache.test.ts +++ b/packages/snap-networks-utils/src/utils/cache/useCache.test.ts @@ -1,6 +1,5 @@ -import type { Serializable } from '@metamask/snap-networks-utils'; - -import type { ICache } from './ICache'; +import type { Serializable } from '../serialization/types'; +import type { ICache } from './types'; import { useCache } from './useCache'; import type { CacheOptions } from './useCache'; @@ -50,11 +49,15 @@ describe('useCache', () => { }; // Define original functions - testFunction = async () => actualExecutionSpy(); - testFunctionWithArgs = async (arg1: string, arg2: number) => - actualExecutionSpy(arg1, arg2); - testFunctionWithComplexArgs = async (obj: { name: string; age: number }) => - actualExecutionSpy(obj); + testFunction = async (): Promise => actualExecutionSpy(); + testFunctionWithArgs = async ( + arg1: string, + arg2: number, + ): Promise => actualExecutionSpy(arg1, arg2); + testFunctionWithComplexArgs = async (obj: { + name: string; + age: number; + }): Promise => actualExecutionSpy(obj); // Create cached versions cachedTestFunction = useCache(testFunction, cache, { @@ -103,6 +106,22 @@ describe('useCache', () => { expect(actualExecutionSpy).not.toHaveBeenCalled(); expect(cache.set).not.toHaveBeenCalled(); }); + + it('should skip the cache and refresh the result if refreshCache is enabled', async () => { + jest.spyOn(cache, 'get').mockResolvedValue('cached-test'); + + const refreshCachedFunction = useCache(testFunction, cache, { + ...cacheOptions, + refreshCache: true, + }); + + const result = await refreshCachedFunction(); + + expect(result).toBe('test'); + expect(cache.get).not.toHaveBeenCalled(); + expect(actualExecutionSpy).toHaveBeenCalledTimes(1); + expect(cache.set).toHaveBeenCalledWith('testFunction:', 'test', 1000); + }); }); describe('error handling', () => { @@ -137,6 +156,30 @@ describe('useCache', () => { expect(result).toBe('test'); expect(actualExecutionSpy).toHaveBeenCalledTimes(1); }); + + it('should log cache errors using the provided logger', async () => { + const errorLogger = { + error: jest.fn(), + }; + + jest + .spyOn(cache, 'get') + .mockRejectedValueOnce(new Error('Cache get error')); + jest + .spyOn(cache, 'set') + .mockRejectedValueOnce(new Error('Cache set error')); + actualExecutionSpy.mockResolvedValueOnce('test'); + + const loggedCachedFunction = useCache(testFunction, cache, { + ...cacheOptions, + logger: errorLogger as never, + }); + + const result = await loggedCachedFunction(); + + expect(result).toBe('test'); + expect(errorLogger.error).toHaveBeenCalledTimes(2); + }); }); describe('different argument types', () => { @@ -187,7 +230,8 @@ describe('useCache', () => { describe('anonymous functions', () => { it('should handle anonymous functions with a default name', async () => { // Anonymous function with no name - const anonymousFunction = async () => actualExecutionSpy(); + const anonymousFunction = async (): Promise => + actualExecutionSpy(); Object.defineProperty(anonymousFunction, 'name', { value: null }); const cachedAnonymousFunction = useCache(anonymousFunction, cache, { @@ -217,7 +261,7 @@ describe('useCache', () => { it('should handle falsy but valid cache values (false, 0, empty string)', async () => { // Test with false jest.spyOn(cache, 'get').mockResolvedValue(false); - let result = await cachedTestFunction(); + let result: unknown = await cachedTestFunction(); expect(result).toBe(false); expect(actualExecutionSpy).not.toHaveBeenCalled(); diff --git a/packages/solana-wallet-snap/src/core/caching/useCache.ts b/packages/snap-networks-utils/src/utils/cache/useCache.ts similarity index 57% rename from packages/solana-wallet-snap/src/core/caching/useCache.ts rename to packages/snap-networks-utils/src/utils/cache/useCache.ts index 028f2584c..fd879b1e3 100644 --- a/packages/solana-wallet-snap/src/core/caching/useCache.ts +++ b/packages/snap-networks-utils/src/utils/cache/useCache.ts @@ -1,9 +1,14 @@ /* eslint-disable no-void */ -import type { Serializable } from '@metamask/snap-networks-utils'; +import { Logger, LogLevel } from '../logger/Logger'; +import { serialize } from '../serialization/serialization'; +import type { Serializable } from '../serialization/types'; +import type { ICache } from './types'; -import logger from '../utils/logger'; -import type { ICache } from './ICache'; +/** + * A logger that discards all messages, used when no logger is provided. + */ +const silentLogger = new Logger({ level: LogLevel.SILENT }); /** * Options for configuring the caching behavior of a function. @@ -21,7 +26,16 @@ export type CacheOptions = { * Optional function to generate the cache key for the function call. * Defaults to a function that generates the key based on function name and JSON stringified args separated by colons. */ - generateCacheKey?: (functionName: string, args: any[]) => string; + generateCacheKey?: (functionName: string, args: Serializable[]) => string; + /** + * Whether to refresh the cache. + * Defaults to false. + */ + refreshCache?: boolean; + /** + * Optional logger for cache errors. Defaults to a silent logger. + */ + logger?: Logger; }; /** @@ -31,11 +45,14 @@ export type CacheOptions = { * @param args - The arguments of the function call. * @returns The cache key. */ -const defaultGenerateCacheKey = (functionName: string, args: any[]) => - `${functionName}:${args.map((arg) => JSON.stringify(arg)).join(':')}`; +const defaultGenerateCacheKey = ( + functionName: string, + args: Serializable[], +): string => + `${functionName}:${args.map((arg) => JSON.stringify(serialize(arg))).join(':')}`; /** - * Wraps an asynchronous function with caching behavior. + * Wraps a function with caching behavior. * * @template TArgs - Tuple type representing the arguments of the function. * @template TResult - The return type of the function, must be Serializable. @@ -43,14 +60,25 @@ const defaultGenerateCacheKey = (functionName: string, args: any[]) => * @param cache - The cache instance to use. * @param options - The caching options. * @param options.ttlMilliseconds - The time to live for the cache in milliseconds. + * @param options.refreshCache - Whether to refresh the cache. * @param options.functionName - The name of the function. * @param options.generateCacheKey - Optional function to generate the cache key. + * @param options.logger - Optional logger for cache errors. * @returns A new asynchronous function with caching behavior. */ -export const useCache = ( +export const useCache = < + TArgs extends Serializable[], + TResult extends Serializable, +>( fn: (...args: TArgs) => Promise, cache: ICache, - { ttlMilliseconds, functionName, generateCacheKey }: CacheOptions, + { + ttlMilliseconds, + functionName, + generateCacheKey, + refreshCache = false, + logger = silentLogger, + }: CacheOptions, ): ((...args: TArgs) => Promise) => { // Use provided key generator or default, adapting the default to use the function's name const _generateCacheKey = generateCacheKey ?? defaultGenerateCacheKey; @@ -60,18 +88,19 @@ export const useCache = ( return async (...args: TArgs): Promise => { const cacheKey = _generateCacheKey(_functionName, args); - // Check if the data is cached - try { - const cached = await cache.get(cacheKey); - // Check explicitly for undefined, as null or other falsy values might be valid cache results - if (cached !== undefined) { - // Type assertion because cache stores Serializable, but we expect TResult - return cached as TResult; + if (!refreshCache) { + try { + const cached = await cache.get(cacheKey); + // Check explicitly for undefined, as null or other falsy values might be valid cache results + if (cached !== undefined) { + // Type assertion because cache stores Serializable, but we expect TResult + return cached as TResult; + } + } catch (error) { + // Log cache get errors but proceed to execute the function + logger.error(`Cache get error for key "${cacheKey}":`, error); } - } catch (error) { - // Log cache get errors but proceed to execute the function - logger.error(`Cache get error for key "${cacheKey}":`, error); } // Execute the original function diff --git a/packages/snap-networks-utils/src/utils/cache/useCacheUntil.test.ts b/packages/snap-networks-utils/src/utils/cache/useCacheUntil.test.ts new file mode 100644 index 000000000..9fa1c24f0 --- /dev/null +++ b/packages/snap-networks-utils/src/utils/cache/useCacheUntil.test.ts @@ -0,0 +1,382 @@ +import type { Serializable } from '../serialization/types'; +import type { ICache } from './types'; +import { useCacheUntil } from './useCacheUntil'; +import type { CacheUntilOptions, ResultWithExpiry } from './useCacheUntil'; + +describe('useCacheUntil', () => { + // Spy to check if the original function was executed or not + let actualExecutionSpy: jest.Mock; + + // Mock cache + let cache: ICache; + + // Common cache options + let cacheOptions: CacheUntilOptions; + + // Original test function that returns result with expiry + let testFunction: () => Promise>; + let testFunctionWithArgs: (arg1: string) => Promise>; + + // Cached versions + let cachedTestFunction: () => Promise; + let cachedTestFunctionWithArgs: (arg1: string) => Promise; + + // Mock current time + const mockNow = 1700000000000; // Fixed timestamp for testing + + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(mockNow); + + // Reset mocks for each test + actualExecutionSpy = jest.fn().mockResolvedValue({ + result: 'test', + expiresAt: mockNow + 60000, // Expires in 60 seconds + }); + + // Create a mock cache + cache = { + get: jest.fn().mockResolvedValue(undefined), + set: jest.fn().mockResolvedValue(undefined), + } as unknown as ICache; + + // Define common cache options + cacheOptions = { + functionName: 'testFunction', + }; + + // Define original functions + testFunction = async (): Promise> => + actualExecutionSpy(); + testFunctionWithArgs = async ( + arg1: string, + ): Promise> => actualExecutionSpy(arg1); + + // Create cached versions + cachedTestFunction = useCacheUntil(testFunction, cache, { + ...cacheOptions, + functionName: 'testFunction', + }); + + cachedTestFunctionWithArgs = useCacheUntil(testFunctionWithArgs, cache, { + ...cacheOptions, + functionName: 'testFunctionWithArgs', + }); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe('when the data is not cached', () => { + it('caches the result with TTL calculated from expiresAt', async () => { + // No cached data + jest.spyOn(cache, 'get').mockResolvedValue(undefined); + + const result = await cachedTestFunction(); + + expect(result).toBe('test'); + expect(actualExecutionSpy).toHaveBeenCalledTimes(1); + // TTL should be expiresAt - now = 60000 + expect(cache.set).toHaveBeenCalledWith('testFunction:', 'test', 60000); + }); + + it('uses zero TTL when expiresAt is in the past', async () => { + actualExecutionSpy.mockResolvedValue({ + result: 'test', + expiresAt: mockNow - 1000, // Already expired + }); + + const result = await cachedTestFunction(); + + expect(result).toBe('test'); + // TTL should be 0 when expiresAt is in the past + expect(cache.set).toHaveBeenCalledWith('testFunction:', 'test', 0); + }); + }); + + describe('when the data is cached and not expired', () => { + it('returns the cached result without calling the function', async () => { + // First call to populate the cache and expiry map + jest.spyOn(cache, 'get').mockResolvedValue(undefined); + await cachedTestFunction(); + + // Reset mocks + actualExecutionSpy.mockClear(); + jest.spyOn(cache, 'get').mockResolvedValue('cached-test'); + + // Second call within expiry period + const result = await cachedTestFunction(); + + expect(result).toBe('cached-test'); + expect(actualExecutionSpy).not.toHaveBeenCalled(); + expect(cache.set).toHaveBeenCalledTimes(1); // Only from first call + }); + + it('hydrates a still-valid entry from the cache after the wrapper is recreated', async () => { + // Simulate a previous run: an entry is persisted in the cache, but the + // wrapper (and its in-memory expiry map) has just been recreated. + jest.spyOn(cache, 'get').mockResolvedValue('persisted-test'); + + const result = await cachedTestFunction(); + + expect(result).toBe('persisted-test'); + expect(actualExecutionSpy).not.toHaveBeenCalled(); + expect(cache.set).not.toHaveBeenCalled(); + }); + + it('skips the cache and refreshes the result if refreshCache is enabled', async () => { + jest.spyOn(cache, 'get').mockResolvedValue('cached-test'); + + const refreshCachedFunction = useCacheUntil(testFunction, cache, { + ...cacheOptions, + refreshCache: true, + }); + + const result = await refreshCachedFunction(); + + expect(result).toBe('test'); + expect(cache.get).not.toHaveBeenCalled(); + expect(actualExecutionSpy).toHaveBeenCalledTimes(1); + expect(cache.set).toHaveBeenCalledWith('testFunction:', 'test', 60000); + }); + }); + + describe('when the data is cached but expired', () => { + it('fetches fresh data after expiry time has passed', async () => { + // First call to populate the cache + jest.spyOn(cache, 'get').mockResolvedValue(undefined); + await cachedTestFunction(); + + // Advance time past the expiry + jest.setSystemTime(mockNow + 70000); // 70 seconds later + + // Reset mocks for second call + actualExecutionSpy.mockClear(); + actualExecutionSpy.mockResolvedValue({ + result: 'fresh-test', + expiresAt: mockNow + 70000 + 60000, // New expiry + }); + + const result = await cachedTestFunction(); + + expect(result).toBe('fresh-test'); + expect(actualExecutionSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('cache key generation', () => { + it('generates cache key with function name and arguments', async () => { + jest.spyOn(cache, 'get').mockResolvedValue(undefined); + actualExecutionSpy.mockResolvedValue({ + result: 'test with args', + expiresAt: mockNow + 60000, + }); + + await cachedTestFunctionWithArgs('hello'); + + expect(cache.set).toHaveBeenCalledWith( + 'testFunctionWithArgs:"hello"', + 'test with args', + 60000, + ); + }); + + it('uses a custom key generator if provided', async () => { + const customKeyGenerator = jest.fn().mockReturnValue('custom-key'); + + const customCachedFunction = useCacheUntil(testFunction, cache, { + ...cacheOptions, + generateCacheKey: customKeyGenerator, + }); + + await customCachedFunction(); + + expect(customKeyGenerator).toHaveBeenCalledTimes(1); + expect(cache.set).toHaveBeenCalledWith('custom-key', 'test', 60000); + }); + }); + + describe('error handling', () => { + it('propagates errors from the original function', async () => { + const error = new Error('Test error'); + actualExecutionSpy.mockRejectedValueOnce(error); + + await expect(cachedTestFunction()).rejects.toThrow('Test error'); + expect(cache.set).not.toHaveBeenCalled(); + }); + + it('handles cache get errors gracefully', async () => { + // First call to populate expiry map + jest.spyOn(cache, 'get').mockResolvedValue(undefined); + await cachedTestFunction(); + + // Reset for second call + actualExecutionSpy.mockClear(); + jest.spyOn(cache, 'get').mockRejectedValueOnce(new Error('Cache error')); + actualExecutionSpy.mockResolvedValue({ + result: 'test', + expiresAt: mockNow + 60000, + }); + + const result = await cachedTestFunction(); + + expect(result).toBe('test'); + expect(actualExecutionSpy).toHaveBeenCalledTimes(1); + }); + + it('handles cache set errors gracefully', async () => { + jest.spyOn(cache, 'get').mockResolvedValue(undefined); + jest + .spyOn(cache, 'set') + .mockRejectedValueOnce(new Error('Cache set error')); + + const result = await cachedTestFunction(); + + expect(result).toBe('test'); + expect(actualExecutionSpy).toHaveBeenCalledTimes(1); + }); + + it('logs cache errors using the provided logger', async () => { + const errorLogger = { + error: jest.fn(), + }; + + jest + .spyOn(cache, 'get') + .mockRejectedValueOnce(new Error('Cache get error')); + jest + .spyOn(cache, 'set') + .mockRejectedValueOnce(new Error('Cache set error')); + + const loggedCachedFunction = useCacheUntil(testFunction, cache, { + ...cacheOptions, + logger: errorLogger as never, + }); + + const result = await loggedCachedFunction(); + + expect(result).toBe('test'); + expect(errorLogger.error).toHaveBeenCalledTimes(2); + }); + }); + + describe('anonymous functions', () => { + it('handles anonymous functions with a default name', async () => { + const anonymousFunction = async (): Promise> => + actualExecutionSpy(); + Object.defineProperty(anonymousFunction, 'name', { value: null }); + + const cachedAnonymousFunction = useCacheUntil(anonymousFunction, cache, { + // No functionName provided + }); + + await cachedAnonymousFunction(); + + expect(cache.set).toHaveBeenCalledWith( + 'anonymousFunction:', + 'test', + 60000, + ); + }); + }); + + describe('function name override', () => { + it('uses the provided function name if given', async () => { + const cachedWithCustomName = useCacheUntil(testFunction, cache, { + functionName: 'customFunctionName', + }); + + await cachedWithCustomName(); + + expect(cache.set).toHaveBeenCalledWith( + 'customFunctionName:', + 'test', + 60000, + ); + }); + }); + + describe('falsy but valid cache values', () => { + it('handles falsy but valid cache values (false, 0, empty string)', async () => { + // First call to populate expiry map with false result + actualExecutionSpy.mockResolvedValue({ + result: false, + expiresAt: mockNow + 60000, + }); + await cachedTestFunction(); + + // Reset and set cache to return false + actualExecutionSpy.mockClear(); + jest.spyOn(cache, 'get').mockResolvedValue(false); + + const result: unknown = await cachedTestFunction(); + + expect(result).toBe(false); + expect(actualExecutionSpy).not.toHaveBeenCalled(); + }); + + it('executes the function when cache returns undefined', async () => { + // First call to populate expiry map + await cachedTestFunction(); + + // Reset for second call with undefined cache + actualExecutionSpy.mockClear(); + jest.spyOn(cache, 'get').mockResolvedValue(undefined); + actualExecutionSpy.mockResolvedValue({ + result: 'fresh', + expiresAt: mockNow + 60000, + }); + + const result = await cachedTestFunction(); + + expect(result).toBe('fresh'); + expect(actualExecutionSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('maintenance-aligned caching scenario', () => { + it('caches until exact maintenance time and refetches after', async () => { + const maintenanceTime = mockNow + 6 * 60 * 60 * 1000; // 6 hours from now + + actualExecutionSpy.mockResolvedValue({ + result: { energyFee: 420, transactionFee: 1000 }, + expiresAt: maintenanceTime, + }); + + // First call - should fetch and cache + await cachedTestFunction(); + + expect(cache.set).toHaveBeenCalledWith( + 'testFunction:', + { energyFee: 420, transactionFee: 1000 }, + 6 * 60 * 60 * 1000, // 6 hours TTL + ); + + // Advance time to just before maintenance + jest.setSystemTime(maintenanceTime - 1000); + actualExecutionSpy.mockClear(); + jest + .spyOn(cache, 'get') + .mockResolvedValue({ energyFee: 420, transactionFee: 1000 }); + + await cachedTestFunction(); + expect(actualExecutionSpy).not.toHaveBeenCalled(); // Still using cache + + // Advance time past maintenance + jest.setSystemTime(maintenanceTime + 1000); + actualExecutionSpy.mockResolvedValue({ + result: { energyFee: 500, transactionFee: 1200 }, // New values + expiresAt: maintenanceTime + 6 * 60 * 60 * 1000, // Next maintenance + }); + + const freshResult = await cachedTestFunction(); + + expect(actualExecutionSpy).toHaveBeenCalledTimes(1); // Fetched fresh + expect(freshResult).toStrictEqual({ + energyFee: 500, + transactionFee: 1200, + }); + }); + }); +}); diff --git a/packages/snap-networks-utils/src/utils/cache/useCacheUntil.ts b/packages/snap-networks-utils/src/utils/cache/useCacheUntil.ts new file mode 100644 index 000000000..55ae37689 --- /dev/null +++ b/packages/snap-networks-utils/src/utils/cache/useCacheUntil.ts @@ -0,0 +1,136 @@ +import { Logger, LogLevel } from '../logger/Logger'; +import { serialize } from '../serialization/serialization'; +import type { Serializable } from '../serialization/types'; +import type { ICache } from './types'; + +/** + * A logger that discards all messages, used when no logger is provided. + */ +const silentLogger = new Logger({ level: LogLevel.SILENT }); + +/** + * Result type for functions that provide their own expiry time. + */ +export type ResultWithExpiry = { + result: TResult; + expiresAt: number; // Unix timestamp in milliseconds +}; + +/** + * Options for configuring the caching behavior of a function with dynamic expiry. + */ +export type CacheUntilOptions = { + /** + * Set this if you want to use a custom function name for the cache key. + */ + functionName?: string; + /** + * Optional function to generate the cache key for the function call. + * Defaults to a function that generates the key based on function name and JSON stringified args separated by colons. + */ + generateCacheKey?: (functionName: string, args: Serializable[]) => string; + /** + * Whether to refresh the cache. + * Defaults to false. + */ + refreshCache?: boolean; + /** + * Optional logger for cache errors. Defaults to a silent logger. + */ + logger?: Logger; +}; + +/** + * Default function to generate the cache key for a function call. + * + * @param functionName - The name of the function. + * @param args - The arguments of the function call. + * @returns The cache key. + */ +const defaultGenerateCacheKey = ( + functionName: string, + args: Serializable[], +): string => + `${functionName}:${args.map((arg) => JSON.stringify(serialize(arg))).join(':')}`; + +/** + * Wraps an async function with caching behavior where expiry is determined + * by the function result itself (dynamic TTL). + * + * Unlike `useCache` which uses a fixed TTL, this utility allows the wrapped + * function to specify when its result expires. This is useful for caching + * data that has known invalidation points (e.g., blockchain maintenance periods). + * + * @template TArgs - Tuple type representing the arguments of the function. + * @template TResult - The return type of the function, must be Serializable. + * @param fn - The asynchronous function to wrap. Must return a Promise>. + * @param cache - The cache instance to use. + * @param options - The caching options. + * @param options.refreshCache - Whether to refresh the cache. + * @param options.functionName - The name of the function. + * @param options.generateCacheKey - Optional function to generate the cache key. + * @param options.logger - Optional logger for cache errors. + * @returns A new asynchronous function with caching behavior. + */ +export const useCacheUntil = < + TArgs extends Serializable[], + TResult extends Serializable, +>( + fn: (...args: TArgs) => Promise>, + cache: ICache, + { + functionName, + generateCacheKey, + refreshCache = false, + logger = silentLogger, + }: CacheUntilOptions, +): ((...args: TArgs) => Promise) => { + // Use provided key generator or default, adapting the default to use the function's name + const _generateCacheKey = generateCacheKey ?? defaultGenerateCacheKey; + + // Get the function name for the default key generator, handle anonymous functions + const _functionName = functionName ?? fn.name ?? 'anonymousFunction'; + + // Map to track expiry timestamps for each cache key + const expiryMap = new Map(); + + return async (...args: TArgs): Promise => { + const cacheKey = _generateCacheKey(_functionName, args); + const now = Date.now(); + + // Check if cached and not expired. + // The cache owns the persisted TTL. When this wrapper is recreated after a + // Snap restart, expiryMap is empty, so consult the cache to hydrate a still + // valid entry instead of fetching it again. + const expiresAt = expiryMap.get(cacheKey); + if (!refreshCache && (expiresAt === undefined || now < expiresAt)) { + try { + const cached = await cache.get(cacheKey); + // Check explicitly for undefined, as null or other falsy values might be valid cache results + if (cached !== undefined) { + // Type assertion because cache stores Serializable, but we expect TResult + return cached as TResult; + } + } catch (error) { + // Log cache get errors but proceed to execute the function + logger.error(`Cache get error for key "${cacheKey}":`, error); + } + } + + // Execute the original function to get result and new expiry + const { result, expiresAt: newExpiresAt } = await fn(...args); + + // Calculate TTL from expiry timestamp + const ttlMilliseconds = Math.max(0, newExpiresAt - now); + + // Store result in cache with calculated TTL + await cache.set(cacheKey, result, ttlMilliseconds).catch((error) => { + logger.error(`Cache set error for key "${cacheKey}":`, error); + }); + + // Store expiry timestamp + expiryMap.set(cacheKey, newExpiresAt); + + return result; + }; +}; diff --git a/packages/solana-wallet-snap/src/core/caching/types.ts b/packages/solana-wallet-snap/src/core/caching/types.ts deleted file mode 100644 index e18316b31..000000000 --- a/packages/solana-wallet-snap/src/core/caching/types.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { Serializable } from '@metamask/snap-networks-utils'; - -export type TimestampMilliseconds = number; - -/** - * A single cache entry. - */ -export type CacheEntry = { - value: Serializable; - expiresAt: TimestampMilliseconds; -}; diff --git a/packages/solana-wallet-snap/src/core/clients/nft-api/NftApiClient.test.ts b/packages/solana-wallet-snap/src/core/clients/nft-api/NftApiClient.test.ts index 77e27281a..ab9b338f2 100644 --- a/packages/solana-wallet-snap/src/core/clients/nft-api/NftApiClient.test.ts +++ b/packages/solana-wallet-snap/src/core/clients/nft-api/NftApiClient.test.ts @@ -1,7 +1,6 @@ -import type { Serializable } from '@metamask/snap-networks-utils'; +import type { Serializable, ICache } from '@metamask/snap-networks-utils'; +import { InMemoryCache } from '@metamask/snap-networks-utils'; -import type { ICache } from '../../caching/ICache'; -import { InMemoryCache } from '../../caching/InMemoryCache'; import { mockLogger } from '../../services/__mocks__/logger'; import type { ConfigProvider } from '../../services/config'; import { trackError } from '../../utils/errors'; diff --git a/packages/solana-wallet-snap/src/core/clients/nft-api/NftApiClient.ts b/packages/solana-wallet-snap/src/core/clients/nft-api/NftApiClient.ts index 72d248eab..fd01f07a1 100644 --- a/packages/solana-wallet-snap/src/core/clients/nft-api/NftApiClient.ts +++ b/packages/solana-wallet-snap/src/core/clients/nft-api/NftApiClient.ts @@ -1,11 +1,9 @@ /* eslint-disable @typescript-eslint/naming-convention */ -import { UrlStruct, buildUrl } from '@metamask/snap-networks-utils'; -import type { Serializable } from '@metamask/snap-networks-utils'; +import { UrlStruct, buildUrl, useCache } from '@metamask/snap-networks-utils'; +import type { ICache, Serializable } from '@metamask/snap-networks-utils'; import { assert } from '@metamask/superstruct'; -import type { ICache } from '../../caching/ICache'; -import { useCache } from '../../caching/useCache'; import type { ConfigProvider } from '../../services/config'; import { trackError } from '../../utils/errors'; import type { diff --git a/packages/solana-wallet-snap/src/core/clients/price-api/PriceApiClient.test.ts b/packages/solana-wallet-snap/src/core/clients/price-api/PriceApiClient.test.ts index b1c066a88..2d7c5a816 100644 --- a/packages/solana-wallet-snap/src/core/clients/price-api/PriceApiClient.test.ts +++ b/packages/solana-wallet-snap/src/core/clients/price-api/PriceApiClient.test.ts @@ -1,10 +1,9 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ import type { CaipAssetType } from '@metamask/keyring-api'; -import type { Serializable } from '@metamask/snap-networks-utils'; +import type { Serializable, ICache } from '@metamask/snap-networks-utils'; +import { InMemoryCache } from '@metamask/snap-networks-utils'; import { cloneDeep } from 'lodash'; -import type { ICache } from '../../caching/ICache'; -import { InMemoryCache } from '../../caching/InMemoryCache'; import { KnownCaip19Id } from '../../constants/solana'; import { mockLogger } from '../../services/__mocks__/logger'; import type { ConfigProvider } from '../../services/config'; diff --git a/packages/solana-wallet-snap/src/core/clients/price-api/PriceApiClient.ts b/packages/solana-wallet-snap/src/core/clients/price-api/PriceApiClient.ts index f7f94189e..589407c86 100644 --- a/packages/solana-wallet-snap/src/core/clients/price-api/PriceApiClient.ts +++ b/packages/solana-wallet-snap/src/core/clients/price-api/PriceApiClient.ts @@ -2,12 +2,15 @@ import type { CaipAssetType } from '@metamask/keyring-api'; import { UrlStruct, buildUrl } from '@metamask/snap-networks-utils'; -import type { Logger, Serializable } from '@metamask/snap-networks-utils'; +import type { + ICache, + Logger, + Serializable, +} from '@metamask/snap-networks-utils'; import { array, assert } from '@metamask/superstruct'; import { CaipAssetTypeStruct } from '@metamask/utils'; import { mapKeys } from 'lodash'; -import type { ICache } from '../../caching/ICache'; import type { ConfigProvider } from '../../services/config'; import logger from '../../utils/logger'; import type { SpotPrices, VsCurrencyParam } from './types'; diff --git a/packages/solana-wallet-snap/src/core/services/assets/AssetsService.test.ts b/packages/solana-wallet-snap/src/core/services/assets/AssetsService.test.ts index fa21c8a2e..c662757c9 100644 --- a/packages/solana-wallet-snap/src/core/services/assets/AssetsService.test.ts +++ b/packages/solana-wallet-snap/src/core/services/assets/AssetsService.test.ts @@ -1,10 +1,9 @@ import { KeyringEvent } from '@metamask/keyring-api'; import { emitSnapKeyringEvent } from '@metamask/keyring-snap-sdk'; -import type { Serializable } from '@metamask/snap-networks-utils'; +import type { Serializable, ICache } from '@metamask/snap-networks-utils'; +import { InMemoryCache } from '@metamask/snap-networks-utils'; import { cloneDeep } from 'lodash'; -import type { ICache } from '../../caching/ICache'; -import { InMemoryCache } from '../../caching/InMemoryCache'; import { MOCK_NFTS_LIST_RESPONSE_MAPPED } from '../../clients/nft-api/mocks/mockNftsListResponseMapped'; import type { NftApiClient } from '../../clients/nft-api/NftApiClient'; import type { TokenApiClient } from '../../clients/token-api-client/TokenApiClient'; diff --git a/packages/solana-wallet-snap/src/core/services/assets/adapters/SnapAssetsAdapter.test.ts b/packages/solana-wallet-snap/src/core/services/assets/adapters/SnapAssetsAdapter.test.ts index 90c471622..74a2802de 100644 --- a/packages/solana-wallet-snap/src/core/services/assets/adapters/SnapAssetsAdapter.test.ts +++ b/packages/solana-wallet-snap/src/core/services/assets/adapters/SnapAssetsAdapter.test.ts @@ -1,8 +1,7 @@ -import type { Serializable } from '@metamask/snap-networks-utils'; +import type { Serializable, ICache } from '@metamask/snap-networks-utils'; +import { InMemoryCache } from '@metamask/snap-networks-utils'; import { cloneDeep } from 'lodash'; -import type { ICache } from '../../../caching/ICache'; -import { InMemoryCache } from '../../../caching/InMemoryCache'; import { MOCK_NFTS_LIST_RESPONSE_MAPPED } from '../../../clients/nft-api/mocks/mockNftsListResponseMapped'; import type { NftApiClient } from '../../../clients/nft-api/NftApiClient'; import type { TokenApiClient } from '../../../clients/token-api-client/TokenApiClient'; diff --git a/packages/solana-wallet-snap/src/core/services/assets/adapters/SnapAssetsAdapter.ts b/packages/solana-wallet-snap/src/core/services/assets/adapters/SnapAssetsAdapter.ts index a1672d6e1..db599acd8 100644 --- a/packages/solana-wallet-snap/src/core/services/assets/adapters/SnapAssetsAdapter.ts +++ b/packages/solana-wallet-snap/src/core/services/assets/adapters/SnapAssetsAdapter.ts @@ -6,7 +6,12 @@ import type { Balance, } from '@metamask/keyring-api'; import { emitSnapKeyringEvent } from '@metamask/keyring-snap-sdk'; -import type { Logger, Serializable } from '@metamask/snap-networks-utils'; +import type { + Logger, + Serializable, + ICache, +} from '@metamask/snap-networks-utils'; +import { useCache } from '@metamask/snap-networks-utils'; import type { FungibleAssetMetadata } from '@metamask/snaps-sdk'; import type { CaipAssetType, CaipChainId } from '@metamask/utils'; import { Duration, parseCaipAssetType } from '@metamask/utils'; @@ -25,8 +30,6 @@ import type { SolanaKeyringAccount, TokenAsset, } from '../../../../entities'; -import type { ICache } from '../../../caching/ICache'; -import { useCache } from '../../../caching/useCache'; import type { NftApiClient } from '../../../clients/nft-api/NftApiClient'; import type { TokenApiClient } from '../../../clients/token-api-client/TokenApiClient'; import { Network, SolanaCaip19Tokens } from '../../../constants/solana'; @@ -268,7 +271,11 @@ export class SnapAssetsAdapter { ttlMilliseconds: SnapAssetsAdapter.cacheTtlsMilliseconds.tokenAccountsByOwner, generateCacheKey: (functionName, args) => { - const [account, programId, scope] = args; + const [account, programId, scope] = args as [ + SolanaKeyringAccount, + Address, + Network, + ]; return `${functionName}:${account.id}:${programId}:${scope}`; }, }); diff --git a/packages/solana-wallet-snap/src/core/services/connection/SolanaConnection.test.ts b/packages/solana-wallet-snap/src/core/services/connection/SolanaConnection.test.ts index 721867c6a..921456b01 100644 --- a/packages/solana-wallet-snap/src/core/services/connection/SolanaConnection.test.ts +++ b/packages/solana-wallet-snap/src/core/services/connection/SolanaConnection.test.ts @@ -1,10 +1,9 @@ /* eslint-disable @typescript-eslint/no-require-imports */ -import type { Serializable } from '@metamask/snap-networks-utils'; +import type { Serializable, ICache } from '@metamask/snap-networks-utils'; +import { InMemoryCache } from '@metamask/snap-networks-utils'; import { fetchJsonParsedAccount } from '@solana/kit'; -import type { ICache } from '../../caching/ICache'; -import { InMemoryCache } from '../../caching/InMemoryCache'; import { KnownCaip19Id, Network } from '../../constants/solana'; import { mockLogger } from '../__mocks__/logger'; import { diff --git a/packages/solana-wallet-snap/src/core/services/connection/SolanaConnection.ts b/packages/solana-wallet-snap/src/core/services/connection/SolanaConnection.ts index 4f889e719..4615087b6 100644 --- a/packages/solana-wallet-snap/src/core/services/connection/SolanaConnection.ts +++ b/packages/solana-wallet-snap/src/core/services/connection/SolanaConnection.ts @@ -1,4 +1,5 @@ -import type { Serializable } from '@metamask/snap-networks-utils'; +import type { Serializable, ICache } from '@metamask/snap-networks-utils'; +import { useCache } from '@metamask/snap-networks-utils'; import { assert } from '@metamask/superstruct'; import { Duration } from '@metamask/utils'; import { fetchMint } from '@solana-program/token-2022'; @@ -12,19 +13,29 @@ import type { Account, Address, Blockhash, + Commitment, FetchAccountConfig, MaybeAccount, MaybeEncodedAccount, + Slot, } from '@solana/kit'; import type { Rpc, SolanaRpcApi } from '@solana/kit'; -import type { ICache } from '../../caching/ICache'; -import { useCache } from '../../caching/useCache'; import type { Network } from '../../constants/solana'; import { NetworkStruct } from '../../validation/structs'; import type { ConfigProvider } from '../config/ConfigProvider'; import { createMainTransport } from './transport'; +/** + * The result of {@link fetchJsonParsedAccount}. + * + * The SDK's account types are not statically `Serializable`, so the result is + * asserted to be cache-safe via an intersection with `Serializable`. + */ +type JsonParsedAccountResult = + | (MaybeAccount & Serializable) + | (MaybeEncodedAccount
& Serializable); + /** * The SolanaConnection class is responsible for managing the connections to the Solana networks. */ @@ -113,17 +124,33 @@ export class SolanaConnection { >; } - // Create a cached version of the function + // Create a cached version of the function. + // The cache key is built from the result-affecting, serializable parts of + // the config (`commitment` and `minContextSlot`). `abortSignal` only + // affects cancellation, so it is neither part of the key nor forwarded to + // the cached fetch. const cached = useCache< - [string, Network, FetchAccountConfig | undefined], - | (MaybeAccount & Serializable) - | (MaybeEncodedAccount
& Serializable) - >(internal as any, this.#cache, { - ttlMilliseconds: this.#cacheTtlsMilliseconds.fetchJsonParsedAccount, - functionName: 'SolanaConnection::fetchJsonParsedAccount', - }); - - return cached(address, caip2Id, config); + [string, Network, Commitment | undefined, Slot | undefined], + JsonParsedAccountResult + >( + async ( + _address, + _caip2Id, + _commitment, + _minContextSlot, + ): Promise> => + fetchJsonParsedAccount(this.getRpc(_caip2Id), asAddress(_address), { + commitment: _commitment, + minContextSlot: _minContextSlot, + }) as Promise>, + this.#cache, + { + ttlMilliseconds: this.#cacheTtlsMilliseconds.fetchJsonParsedAccount, + functionName: 'SolanaConnection::fetchJsonParsedAccount', + }, + ); + + return cached(address, caip2Id, config?.commitment, config?.minContextSlot); } /** @@ -146,28 +173,40 @@ export class SolanaConnection { * * This wrapper is used instead of directly caching the SDK's fetchMint function * to ensure that only simple arguments are used, as these arguments form the cache key. + * + * As with {@link SolanaConnection.fetchJsonParsedAccount}, the cache key is + * built from the result-affecting, serializable parts of the config + * (`commitment` and `minContextSlot`); `abortSignal` is not forwarded to + * the cached fetch. */ - - const fetchMintInternal = async ( - _address: Address, - _caip2Id: Network, - _config?: FetchAccountConfig, - ) => { - const rpc = this.getRpc(caip2Id); - return fetchMint(rpc, asAddress(address), config); - }; - - // Create a cached version of the function const fetchMintCached = useCache< - [Address, Network, FetchAccountConfig | undefined], + [Address, Network, Commitment | undefined, Slot | undefined], Account & Serializable - >(fetchMintInternal as any, this.#cache, { - ttlMilliseconds: this.#cacheTtlsMilliseconds.fetchMint, - functionName: 'SolanaConnection::fetchMint', - }); + >( + async ( + _address, + _caip2Id, + _commitment, + _minContextSlot, + ): Promise & Serializable> => + fetchMint(this.getRpc(_caip2Id), _address, { + commitment: _commitment, + minContextSlot: _minContextSlot, + }) as Promise & Serializable>, + this.#cache, + { + ttlMilliseconds: this.#cacheTtlsMilliseconds.fetchMint, + functionName: 'SolanaConnection::fetchMint', + }, + ); // Use the cached version of the function - return fetchMintCached(asAddress(address), caip2Id, config); + return fetchMintCached( + asAddress(address), + caip2Id, + config?.commitment, + config?.minContextSlot, + ); } /** diff --git a/packages/solana-wallet-snap/src/core/services/send/SendService.test.ts b/packages/solana-wallet-snap/src/core/services/send/SendService.test.ts index 511982d7e..9f373c7a3 100644 --- a/packages/solana-wallet-snap/src/core/services/send/SendService.test.ts +++ b/packages/solana-wallet-snap/src/core/services/send/SendService.test.ts @@ -1,10 +1,9 @@ import { SolMethod } from '@metamask/keyring-api'; -import type { Serializable } from '@metamask/snap-networks-utils'; +import type { Serializable, ICache } from '@metamask/snap-networks-utils'; +import { InMemoryCache } from '@metamask/snap-networks-utils'; import { lamports } from '@solana/kit'; import type { AssetEntity, SolanaKeyringAccount } from '../../../entities'; -import type { ICache } from '../../caching/ICache'; -import { InMemoryCache } from '../../caching/InMemoryCache'; import { KnownCaip19Id, METAMASK_ORIGIN, diff --git a/packages/solana-wallet-snap/src/core/services/send/SendService.ts b/packages/solana-wallet-snap/src/core/services/send/SendService.ts index 92cd1787f..b072eab63 100644 --- a/packages/solana-wallet-snap/src/core/services/send/SendService.ts +++ b/packages/solana-wallet-snap/src/core/services/send/SendService.ts @@ -1,13 +1,16 @@ import type { KeyringRequest } from '@metamask/keyring-api'; import { SolMethod } from '@metamask/keyring-api'; -import type { Logger, Serializable } from '@metamask/snap-networks-utils'; +import type { + Logger, + Serializable, + ICache, +} from '@metamask/snap-networks-utils'; +import { useCache } from '@metamask/snap-networks-utils'; import type { Json } from '@metamask/snaps-sdk'; import { Duration, parseCaipAssetType } from '@metamask/utils'; import { address as asAddress, compileTransaction } from '@solana/kit'; import { BigNumber } from 'bignumber.js'; -import type { ICache } from '../../caching/ICache'; -import { useCache } from '../../caching/useCache'; import type { Network } from '../../constants/solana'; import { METAMASK_ORIGIN, Networks } from '../../constants/solana'; import type { SolanaKeyring } from '../../handlers/onKeyringRequest/Keyring'; @@ -83,7 +86,7 @@ export class SendService { ttlMilliseconds: this.#cacheTtlsMilliseconds.minimumBalanceForRentExemption, generateCacheKey: (functionName, args) => { - const [_scope] = args; + const [_scope] = args as [Network]; return `${functionName}:${_scope}`; }, }, diff --git a/packages/solana-wallet-snap/src/snapContext.ts b/packages/solana-wallet-snap/src/snapContext.ts index 0e8085914..76c8948e5 100644 --- a/packages/solana-wallet-snap/src/snapContext.ts +++ b/packages/solana-wallet-snap/src/snapContext.ts @@ -1,4 +1,5 @@ -import { InMemoryCache } from './core/caching/InMemoryCache'; +import { InMemoryCache } from '@metamask/snap-networks-utils'; + import { NftApiClient } from './core/clients/nft-api/NftApiClient'; import { PriceApiClient } from './core/clients/price-api/PriceApiClient'; import { SecurityAlertsApiClient } from './core/clients/security-alerts-api/SecurityAlertsApiClient';