diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 1250d59a6..9e6248fa2 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -1354,26 +1354,6 @@ "count": 2 } }, - "packages/stellar-wallet-snap/src/services/cache/StateCache.test.ts": { - "@typescript-eslint/explicit-function-return-type": { - "count": 1 - } - }, - "packages/stellar-wallet-snap/src/services/cache/StateCache.ts": { - "no-restricted-syntax": { - "count": 1 - } - }, - "packages/stellar-wallet-snap/src/services/cache/useCache.test.ts": { - "@typescript-eslint/explicit-function-return-type": { - "count": 4 - } - }, - "packages/stellar-wallet-snap/src/services/cache/useCacheUntil.test.ts": { - "@typescript-eslint/explicit-function-return-type": { - "count": 2 - } - }, "packages/stellar-wallet-snap/src/services/network/NetworkService.test.ts": { "@typescript-eslint/explicit-function-return-type": { "count": 4 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/stellar-wallet-snap/src/services/cache/InMemoryCache.ts b/packages/snap-networks-utils/src/utils/cache/InMemoryCache.ts similarity index 95% rename from packages/stellar-wallet-snap/src/services/cache/InMemoryCache.ts rename to packages/snap-networks-utils/src/utils/cache/InMemoryCache.ts index 8b1c28dcd..a4cf059e4 100644 --- a/packages/stellar-wallet-snap/src/services/cache/InMemoryCache.ts +++ b/packages/snap-networks-utils/src/utils/cache/InMemoryCache.ts @@ -1,7 +1,8 @@ -import type { Logger, Serializable } from '@metamask/snap-networks-utils'; import { assert } from '@metamask/utils'; -import type { ICache, CacheEntry } from './api'; +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. @@ -65,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, ), }); diff --git a/packages/stellar-wallet-snap/src/services/cache/StateCache.test.ts b/packages/snap-networks-utils/src/utils/cache/StateCache.test.ts similarity index 90% rename from packages/stellar-wallet-snap/src/services/cache/StateCache.test.ts rename to packages/snap-networks-utils/src/utils/cache/StateCache.test.ts index 5743cd7fd..4c7884d51 100644 --- a/packages/stellar-wallet-snap/src/services/cache/StateCache.test.ts +++ b/packages/snap-networks-utils/src/utils/cache/StateCache.test.ts @@ -1,16 +1,61 @@ /* eslint-disable jest/prefer-strict-equal */ -import { logger } from '../../utils/logger'; -import type { IStateManager } from '../state'; -import { InMemoryState } from './InMemoryState'; +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'; -jest.mock('../../utils/logger'); +/** + * 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', () => { - const createStateCache = (state: IStateManager) => - new StateCache(state, logger); + 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', () => { @@ -20,9 +65,8 @@ describe('StateCache', () => { }); it('uses the specified prefix if provided', () => { - const cache = new StateCache( + const cache = createStateCache( new InMemoryState({}), - logger, '__cache__my-prefix', ); @@ -36,7 +80,7 @@ describe('StateCache', () => { 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 = new StateCache(stateWithNoCache, logger); + const cache = createStateCache(stateWithNoCache); const value = await cache.get('someKey'); @@ -298,6 +342,21 @@ describe('StateCache', () => { 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', () => { @@ -307,7 +366,6 @@ describe('StateCache', () => { someKey: { value: 'someValue', expiresAt: Number.MAX_SAFE_INTEGER, - createdAt: 1704067200000, // January 1, 2024 }, }, }); @@ -556,6 +614,21 @@ describe('StateCache', () => { 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); diff --git a/packages/stellar-wallet-snap/src/services/cache/StateCache.ts b/packages/snap-networks-utils/src/utils/cache/StateCache.ts similarity index 82% rename from packages/stellar-wallet-snap/src/services/cache/StateCache.ts rename to packages/snap-networks-utils/src/utils/cache/StateCache.ts index 7858ac5f1..b3d6458c1 100644 --- a/packages/stellar-wallet-snap/src/services/cache/StateCache.ts +++ b/packages/snap-networks-utils/src/utils/cache/StateCache.ts @@ -1,8 +1,24 @@ -import type { Logger, Serializable } from '@metamask/snap-networks-utils'; import { assert } from '@metamask/utils'; -import type { IStateManager } from '../state/IStateManager'; -import type { ICache, CacheEntry } from './api'; +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. @@ -24,9 +40,9 @@ export type StateValue = { }; /** - * A cache that wraps any implementation of the `IStateManager` interface to store the cache. + * 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 `IStateManager` interface. For instance it can be used with the `InMemoryState` class for testing purposes. + * 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. @@ -69,19 +85,19 @@ export type StateValue = { * ``` */ export class StateCache implements ICache { - readonly #state: IStateManager; - - public readonly prefix: CachePrefix; + readonly #state: CacheStateManager; readonly #logger: Logger; + public readonly prefix: CachePrefix; + constructor( - state: IStateManager, + state: CacheStateManager, logger: Logger, prefix: CachePrefix = '__cache__default', ) { this.#state = state; - this.#logger = logger.withPrefix('[💾 StateCache]'); + this.#logger = logger; this.prefix = prefix; } @@ -100,7 +116,7 @@ export class StateCache implements ICache { await this.#state.setKey(`${this.prefix}.${key}`, { value, expiresAt: Math.min( - Date.now() + (ttlMilliseconds ?? Number.MAX_SAFE_INTEGER), + Date.now() + ttlMilliseconds, Number.MAX_SAFE_INTEGER, ), }); @@ -139,13 +155,13 @@ export class StateCache implements ICache { } async keys(): Promise { - const cacheStore = await this.#state.getKey(this.prefix); + const cacheStore = await this.#state.getKey(this.prefix); return Object.keys(cacheStore ?? {}); } async size(): Promise { - const cacheStore = await this.#state.getKey(this.prefix); + const cacheStore = await this.#state.getKey(this.prefix); return Object.keys(cacheStore ?? {}).length; } @@ -160,7 +176,7 @@ export class StateCache implements ICache { async mget( keys: string[], ): Promise> { - const cacheStore = await this.#state.getKey(this.prefix); + const cacheStore = await this.#state.getKey(this.prefix); // If cache is not initialized, return empty object if (!cacheStore) { @@ -199,7 +215,7 @@ export class StateCache implements ICache { // Then, handle keys that don't exist in the cache keys.forEach((key) => { - if (!(key in result)) { + if (!Object.hasOwn(result, key)) { this.#logger.info(`[StateCache] ❌ Cache miss for key "${key}"`); result[key] = undefined; } diff --git a/packages/stellar-wallet-snap/src/services/cache/api.ts b/packages/snap-networks-utils/src/utils/cache/types.ts similarity index 97% rename from packages/stellar-wallet-snap/src/services/cache/api.ts rename to packages/snap-networks-utils/src/utils/cache/types.ts index 883bd4a99..82a3b7581 100644 --- a/packages/stellar-wallet-snap/src/services/cache/api.ts +++ b/packages/snap-networks-utils/src/utils/cache/types.ts @@ -1,4 +1,4 @@ -import type { Serializable } from '@metamask/snap-networks-utils'; +import type { Serializable } from '../serialization/types'; export type TimestampMilliseconds = number; diff --git a/packages/stellar-wallet-snap/src/services/cache/useCache.test.ts b/packages/snap-networks-utils/src/utils/cache/useCache.test.ts similarity index 80% rename from packages/stellar-wallet-snap/src/services/cache/useCache.test.ts rename to packages/snap-networks-utils/src/utils/cache/useCache.test.ts index 4201d6c9f..13eee88c7 100644 --- a/packages/stellar-wallet-snap/src/services/cache/useCache.test.ts +++ b/packages/snap-networks-utils/src/utils/cache/useCache.test.ts @@ -1,11 +1,8 @@ -import type { Serializable } from '@metamask/snap-networks-utils'; - -import type { ICache } from './api'; +import type { Serializable } from '../serialization/types'; +import type { ICache } from './types'; import { useCache } from './useCache'; import type { CacheOptions } from './useCache'; -jest.mock('../../utils/logger'); - describe('useCache', () => { // Spy to check if the original function was executed or not let actualExecutionSpy: jest.Mock; @@ -52,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, { @@ -105,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', () => { @@ -139,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', () => { @@ -189,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, { @@ -219,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/stellar-wallet-snap/src/services/cache/useCache.ts b/packages/snap-networks-utils/src/utils/cache/useCache.ts similarity index 83% rename from packages/stellar-wallet-snap/src/services/cache/useCache.ts rename to packages/snap-networks-utils/src/utils/cache/useCache.ts index 5d0ce07ca..fd879b1e3 100644 --- a/packages/stellar-wallet-snap/src/services/cache/useCache.ts +++ b/packages/snap-networks-utils/src/utils/cache/useCache.ts @@ -1,12 +1,15 @@ /* eslint-disable no-void */ -import { serialize } from '@metamask/snap-networks-utils'; -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 './api'; +/** + * A logger that discards all messages, used when no logger is provided. + */ +const silentLogger = new Logger({ level: LogLevel.SILENT }); -const cacheLogger = logger.withPrefix('useCache'); /** * Options for configuring the caching behavior of a function. */ @@ -24,12 +27,15 @@ export type CacheOptions = { * 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; }; /** @@ -57,6 +63,7 @@ const defaultGenerateCacheKey = ( * @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 = < @@ -70,6 +77,7 @@ export const useCache = < 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 @@ -91,7 +99,7 @@ export const useCache = < } } catch (error) { // Log cache get errors but proceed to execute the function - cacheLogger.error(`Cache get error for key "${cacheKey}":`, error); + logger.error(`Cache get error for key "${cacheKey}":`, error); } } @@ -101,7 +109,7 @@ export const useCache = < // Cache the result, handle potential errors silently // We don't await this, allowing it to happen in the background void cache.set(cacheKey, result, ttlMilliseconds).catch((error) => { - cacheLogger.error(`Cache set error for key "${cacheKey}":`, error); + logger.error(`Cache set error for key "${cacheKey}":`, error); }); return result; diff --git a/packages/stellar-wallet-snap/src/services/cache/useCacheUntil.test.ts b/packages/snap-networks-utils/src/utils/cache/useCacheUntil.test.ts similarity index 83% rename from packages/stellar-wallet-snap/src/services/cache/useCacheUntil.test.ts rename to packages/snap-networks-utils/src/utils/cache/useCacheUntil.test.ts index c78f6a4e5..9fa1c24f0 100644 --- a/packages/stellar-wallet-snap/src/services/cache/useCacheUntil.test.ts +++ b/packages/snap-networks-utils/src/utils/cache/useCacheUntil.test.ts @@ -1,11 +1,8 @@ -import type { Serializable } from '@metamask/snap-networks-utils'; - -import type { ICache } from './api'; +import type { Serializable } from '../serialization/types'; +import type { ICache } from './types'; import { useCacheUntil } from './useCacheUntil'; import type { CacheUntilOptions, ResultWithExpiry } from './useCacheUntil'; -jest.mock('../../utils/logger'); - describe('useCacheUntil', () => { // Spy to check if the original function was executed or not let actualExecutionSpy: jest.Mock; @@ -49,8 +46,11 @@ describe('useCacheUntil', () => { }; // Define original functions - testFunction = async () => actualExecutionSpy(); - testFunctionWithArgs = async (arg1: string) => actualExecutionSpy(arg1); + testFunction = async (): Promise> => + actualExecutionSpy(); + testFunctionWithArgs = async ( + arg1: string, + ): Promise> => actualExecutionSpy(arg1); // Create cached versions cachedTestFunction = useCacheUntil(testFunction, cache, { @@ -112,6 +112,34 @@ describe('useCacheUntil', () => { 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', () => { @@ -208,6 +236,29 @@ describe('useCacheUntil', () => { 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', () => { @@ -259,7 +310,7 @@ describe('useCacheUntil', () => { actualExecutionSpy.mockClear(); jest.spyOn(cache, 'get').mockResolvedValue(false); - const result = await cachedTestFunction(); + const result: unknown = await cachedTestFunction(); expect(result).toBe(false); expect(actualExecutionSpy).not.toHaveBeenCalled(); diff --git a/packages/stellar-wallet-snap/src/services/cache/useCacheUntil.ts b/packages/snap-networks-utils/src/utils/cache/useCacheUntil.ts similarity index 78% rename from packages/stellar-wallet-snap/src/services/cache/useCacheUntil.ts rename to packages/snap-networks-utils/src/utils/cache/useCacheUntil.ts index 6a3cd19bf..55ae37689 100644 --- a/packages/stellar-wallet-snap/src/services/cache/useCacheUntil.ts +++ b/packages/snap-networks-utils/src/utils/cache/useCacheUntil.ts @@ -1,10 +1,12 @@ -import { serialize } from '@metamask/snap-networks-utils'; -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 './api'; - -const cacheLogger = logger.withPrefix('useCache'); +/** + * 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. @@ -27,12 +29,15 @@ export type CacheUntilOptions = { * 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; }; /** @@ -64,6 +69,7 @@ const defaultGenerateCacheKey = ( * @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 = < @@ -72,7 +78,12 @@ export const useCacheUntil = < >( fn: (...args: TArgs) => Promise>, cache: ICache, - { functionName, generateCacheKey, refreshCache = false }: CacheUntilOptions, + { + 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; @@ -87,9 +98,12 @@ export const useCacheUntil = < const cacheKey = _generateCacheKey(_functionName, args); const now = Date.now(); - // Check if cached and not expired + // 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) { + 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 @@ -99,7 +113,7 @@ export const useCacheUntil = < } } catch (error) { // Log cache get errors but proceed to execute the function - cacheLogger.error(`Cache get error for key "${cacheKey}":`, error); + logger.error(`Cache get error for key "${cacheKey}":`, error); } } @@ -111,7 +125,7 @@ export const useCacheUntil = < // Store result in cache with calculated TTL await cache.set(cacheKey, result, ttlMilliseconds).catch((error) => { - cacheLogger.error(`Cache set error for key "${cacheKey}":`, error); + logger.error(`Cache set error for key "${cacheKey}":`, error); }); // Store expiry timestamp diff --git a/packages/stellar-wallet-snap/src/context.ts b/packages/stellar-wallet-snap/src/context.ts index d29ec967b..cd09e02dc 100644 --- a/packages/stellar-wallet-snap/src/context.ts +++ b/packages/stellar-wallet-snap/src/context.ts @@ -1,3 +1,4 @@ +import { InMemoryCache } from '@metamask/snap-networks-utils'; import { assert, object } from '@metamask/superstruct'; import { AppConfig } from './config'; @@ -38,7 +39,6 @@ import { AssetMetadataRepository, AssetMetadataService, } from './services/asset-metadata'; -import { InMemoryCache } from './services/cache'; import { NetworkService } from './services/network'; import { OnChainAccountRepository, diff --git a/packages/stellar-wallet-snap/src/handlers/cronjob/trackTransaction.test.ts b/packages/stellar-wallet-snap/src/handlers/cronjob/trackTransaction.test.ts index 6e267198f..566d276ea 100644 --- a/packages/stellar-wallet-snap/src/handlers/cronjob/trackTransaction.test.ts +++ b/packages/stellar-wallet-snap/src/handlers/cronjob/trackTransaction.test.ts @@ -1,11 +1,11 @@ import { TransactionStatus } from '@metamask/keyring-api'; +import { InMemoryCache } from '@metamask/snap-networks-utils'; import { KnownCaip2ChainId } from '../../api'; import { AppConfig } from '../../config'; import { KEYRING_ACCOUNT_TYPE, METAMASK_ORIGIN } from '../../constants'; import { AccountService } from '../../services/account'; import { generateStellarKeyringAccount } from '../../services/account/__mocks__/account.fixtures'; -import { InMemoryCache } from '../../services/cache'; import { NetworkService, NetworkServiceException, diff --git a/packages/stellar-wallet-snap/src/services/asset-metadata/__mocks__/assets.fixtures.ts b/packages/stellar-wallet-snap/src/services/asset-metadata/__mocks__/assets.fixtures.ts index 9fcef6a6e..5b80152cf 100644 --- a/packages/stellar-wallet-snap/src/services/asset-metadata/__mocks__/assets.fixtures.ts +++ b/packages/stellar-wallet-snap/src/services/asset-metadata/__mocks__/assets.fixtures.ts @@ -1,9 +1,10 @@ +import { InMemoryCache } from '@metamask/snap-networks-utils'; + import type { KnownCaip19AssetIdOrSlip44Id } from '../../../api'; import { AssetType, KnownCaip2ChainId } from '../../../api'; import { NATIVE_ASSET_NAME, NATIVE_ASSET_SYMBOL } from '../../../constants'; import { getSlip44AssetId } from '../../../utils/caip'; import { logger, noOpLogger } from '../../../utils/logger'; -import { InMemoryCache } from '../../cache'; import { NetworkService } from '../../network'; import { State } from '../../state'; import type { AssetMetadataByAssetId, StellarAssetMetadata } from '../api'; diff --git a/packages/stellar-wallet-snap/src/services/cache/InMemoryState.ts b/packages/stellar-wallet-snap/src/services/cache/InMemoryState.ts deleted file mode 100644 index 02513d0f4..000000000 --- a/packages/stellar-wallet-snap/src/services/cache/InMemoryState.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { Serializable } from '@metamask/snap-networks-utils'; -import { get, set, unset } from 'lodash'; - -import type { IStateManager } from '../state/IStateManager'; - -/** - * A simple implementation of the `IStateManager` interface that relies on an in memory state that can be used for testing purposes. - */ -export class InMemoryState< - TStateValue extends Record, -> implements IStateManager { - #state: TStateValue; - - constructor(initialState: TStateValue) { - this.#state = initialState; - } - - async get(): Promise { - return this.#state; - } - - async getKey( - key: string, - ): Promise { - const value = get(this.#state, key); - - return value as TResponse | 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: TStateValue) => TStateValue, - ): Promise { - this.#state = callback(this.#state); - - return this.#state; - } - - async deleteKey(key: string): Promise { - // Using lodash's unset to leverage the json path capabilities - unset(this.#state, key); - } -} diff --git a/packages/stellar-wallet-snap/src/services/cache/index.ts b/packages/stellar-wallet-snap/src/services/cache/index.ts deleted file mode 100644 index b938e2dab..000000000 --- a/packages/stellar-wallet-snap/src/services/cache/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from './StateCache'; -export * from './InMemoryCache'; -export type * from './api'; -export * from './useCacheUntil'; -export * from './useCache'; diff --git a/packages/stellar-wallet-snap/src/services/network/NetworkService.test.ts b/packages/stellar-wallet-snap/src/services/network/NetworkService.test.ts index 454be3ec3..bdeeadb95 100644 --- a/packages/stellar-wallet-snap/src/services/network/NetworkService.test.ts +++ b/packages/stellar-wallet-snap/src/services/network/NetworkService.test.ts @@ -1,4 +1,5 @@ import { TransactionStatus } from '@metamask/keyring-api'; +import { InMemoryCache } from '@metamask/snap-networks-utils'; import { Account, Contract, @@ -20,7 +21,6 @@ import { AppConfig } from '../../config'; import { STELLAR_DECIMAL_PLACES } from '../../constants'; import { toSmallestUnit } from '../../utils'; import { logger } from '../../utils/logger'; -import { InMemoryCache } from '../cache/InMemoryCache'; import { createMockAccountWithBalances } from '../on-chain-account/__mocks__/onChainAccount.fixtures'; import { OnChainAccount } from '../on-chain-account/OnChainAccount'; import { diff --git a/packages/stellar-wallet-snap/src/services/network/NetworkService.ts b/packages/stellar-wallet-snap/src/services/network/NetworkService.ts index a74cd4415..36f9ede65 100644 --- a/packages/stellar-wallet-snap/src/services/network/NetworkService.ts +++ b/packages/stellar-wallet-snap/src/services/network/NetworkService.ts @@ -1,4 +1,9 @@ -import type { Logger, Serializable } from '@metamask/snap-networks-utils'; +import type { + ICache, + Logger, + Serializable, +} from '@metamask/snap-networks-utils'; +import { useCache } from '@metamask/snap-networks-utils'; import { parseCaipAssetType } from '@metamask/utils'; import { Address, @@ -29,8 +34,6 @@ import { rethrowIfInstanceElseThrow, batchesAllSettled, } from '../../utils'; -import type { ICache } from '../cache'; -import { useCache } from '../cache'; import { OnChainAccount } from '../on-chain-account/OnChainAccount'; import { InvalidInvokeContractStructureException } from '../transaction/exceptions'; import { Transaction } from '../transaction/Transaction'; diff --git a/packages/stellar-wallet-snap/src/services/on-chain-account/__mocks__/onChainAccount.fixtures.ts b/packages/stellar-wallet-snap/src/services/on-chain-account/__mocks__/onChainAccount.fixtures.ts index 585b7c36d..1ecb8141e 100644 --- a/packages/stellar-wallet-snap/src/services/on-chain-account/__mocks__/onChainAccount.fixtures.ts +++ b/packages/stellar-wallet-snap/src/services/on-chain-account/__mocks__/onChainAccount.fixtures.ts @@ -1,4 +1,5 @@ /* eslint-disable @typescript-eslint/naming-convention */ +import { InMemoryCache } from '@metamask/snap-networks-utils'; import type { Horizon } from '@stellar/stellar-sdk'; import { Account } from '@stellar/stellar-sdk'; @@ -6,7 +7,6 @@ import type { KnownCaip2ChainId } from '../../../api'; import { logger, noOpLogger } from '../../../utils/logger'; import { AccountService } from '../../account/AccountService'; import { AccountsRepository } from '../../account/AccountsRepository'; -import { InMemoryCache } from '../../cache'; import { NetworkService } from '../../network'; import { State } from '../../state/State'; import { WalletService } from '../../wallet'; diff --git a/packages/stellar-wallet-snap/src/services/price/PriceService.test.ts b/packages/stellar-wallet-snap/src/services/price/PriceService.test.ts index 8e740b836..5f78fa437 100644 --- a/packages/stellar-wallet-snap/src/services/price/PriceService.test.ts +++ b/packages/stellar-wallet-snap/src/services/price/PriceService.test.ts @@ -3,7 +3,7 @@ import type { CaipAssetType } from '@metamask/utils'; import type { KnownCaip19AssetIdOrSlip44Id } from '../../api'; import { AppConfig } from '../../config'; import { logger } from '../../utils'; -import { createMemoryCache } from '../cache/__mocks__/cache.fixtures'; +import { createMemoryCache } from '../../utils/__mocks__/cache.fixtures'; import type { SpotPrice } from './price-api/api'; import { PriceApiClient } from './price-api/PriceApiClient'; import { PriceService } from './PriceService'; diff --git a/packages/stellar-wallet-snap/src/services/price/PriceService.ts b/packages/stellar-wallet-snap/src/services/price/PriceService.ts index f2bae0024..59ed34445 100644 --- a/packages/stellar-wallet-snap/src/services/price/PriceService.ts +++ b/packages/stellar-wallet-snap/src/services/price/PriceService.ts @@ -1,9 +1,12 @@ -import type { Logger, Serializable } from '@metamask/snap-networks-utils'; +import type { + ICache, + Logger, + Serializable, +} from '@metamask/snap-networks-utils'; import type { CaipAssetType } from '@metamask/utils'; import { AppConfig } from '../../config'; import { trackError } from '../../utils'; -import type { ICache } from '../cache'; import type { SpotPrice, SpotPricesResponse, diff --git a/packages/stellar-wallet-snap/src/services/transaction/TransactionSynchronizeService.test.ts b/packages/stellar-wallet-snap/src/services/transaction/TransactionSynchronizeService.test.ts index fa2f32a4f..926ce5579 100644 --- a/packages/stellar-wallet-snap/src/services/transaction/TransactionSynchronizeService.test.ts +++ b/packages/stellar-wallet-snap/src/services/transaction/TransactionSynchronizeService.test.ts @@ -10,13 +10,13 @@ import { Keypair, Networks } from '@stellar/stellar-sdk'; import { KnownCaip2ChainId } from '../../api'; import { toCaip19Sep41AssetId } from '../../utils'; +import { createMemoryCache } from '../../utils/__mocks__/cache.fixtures'; import { logger } from '../../utils/logger'; import { getSnapProvider } from '../../utils/snap'; import { generateStellarKeyringAccount } from '../account/__mocks__/account.fixtures'; import type { AccountService } from '../account/AccountService'; import type { StellarAssetMetadata } from '../asset-metadata/api'; import { toStellarAssetMetadata } from '../asset-metadata/utils'; -import { createMemoryCache } from '../cache/__mocks__/cache.fixtures'; import { NetworkService, TransactionNotFoundException } from '../network'; import { createMockAccountWithBalances, diff --git a/packages/stellar-wallet-snap/src/services/transaction/__mocks__/transaction.fixtures.ts b/packages/stellar-wallet-snap/src/services/transaction/__mocks__/transaction.fixtures.ts index c24348a7c..0100e7fab 100644 --- a/packages/stellar-wallet-snap/src/services/transaction/__mocks__/transaction.fixtures.ts +++ b/packages/stellar-wallet-snap/src/services/transaction/__mocks__/transaction.fixtures.ts @@ -15,8 +15,8 @@ import { import type { KnownCaip19AssetIdOrSlip44Id } from '../../../api'; import { KnownCaip2ChainId } from '../../../api'; import { getSlip44AssetId, logger } from '../../../utils'; +import { createMemoryCache } from '../../../utils/__mocks__/cache.fixtures'; import { mockAccountService } from '../../account/__mocks__/account.fixtures'; -import { createMemoryCache } from '../../cache/__mocks__/cache.fixtures'; import { NetworkService } from '../../network'; import { State } from '../../state/State'; import { generateStellarAddress } from '../../wallet/__mocks__/wallet.fixtures'; diff --git a/packages/stellar-wallet-snap/src/services/cache/__mocks__/cache.fixtures.ts b/packages/stellar-wallet-snap/src/utils/__mocks__/cache.fixtures.ts similarity index 93% rename from packages/stellar-wallet-snap/src/services/cache/__mocks__/cache.fixtures.ts rename to packages/stellar-wallet-snap/src/utils/__mocks__/cache.fixtures.ts index 1c9da6782..ab230d818 100644 --- a/packages/stellar-wallet-snap/src/services/cache/__mocks__/cache.fixtures.ts +++ b/packages/stellar-wallet-snap/src/utils/__mocks__/cache.fixtures.ts @@ -1,6 +1,4 @@ -import type { Serializable } from '@metamask/snap-networks-utils'; - -import type { ICache } from '../api'; +import type { ICache, Serializable } from '@metamask/snap-networks-utils'; /** * In-memory {@link ICache} for tests (jest.fn wrappers + backing map).