From 97c3041d92dd1ddd244ab575ded53df830388c76 Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Wed, 2 Sep 2026 18:31:05 +0100 Subject: [PATCH] feat(bitcoin-wallet-snap)!: remove asset handler entry points Remove the onAssetsLookup, onAssetsConversion, onAssetHistoricalPrice, and onAssetsMarketData entry points, along with the now-unused AssetsHandler, AssetsUseCases, InMemoryCache, ICache, and the endowment:assets permission. Closes WPN-2012 --- packages/bitcoin-wallet-snap/CHANGELOG.md | 4 + packages/bitcoin-wallet-snap/jest.config.mjs | 8 +- .../bitcoin-wallet-snap/snap.manifest.json | 11 +- packages/bitcoin-wallet-snap/src/config.ts | 1 - .../src/entities/config.ts | 1 - .../src/handlers/AssetsHandler.test.ts | 237 -------- .../src/handlers/AssetsHandler.ts | 215 ------- .../bitcoin-wallet-snap/src/handlers/index.ts | 1 - packages/bitcoin-wallet-snap/src/index.ts | 35 -- .../bitcoin-wallet-snap/src/store/ICache.ts | 124 ---- .../src/store/InMemoryCache.test.ts | 528 ------------------ .../src/store/InMemoryCache.ts | 171 ------ .../src/use-cases/AssetsUseCases.test.ts | 201 ------- .../src/use-cases/AssetsUseCases.ts | 171 ------ .../src/use-cases/index.ts | 1 - 15 files changed, 9 insertions(+), 1700 deletions(-) delete mode 100644 packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.test.ts delete mode 100644 packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.ts delete mode 100644 packages/bitcoin-wallet-snap/src/store/ICache.ts delete mode 100644 packages/bitcoin-wallet-snap/src/store/InMemoryCache.test.ts delete mode 100644 packages/bitcoin-wallet-snap/src/store/InMemoryCache.ts delete mode 100644 packages/bitcoin-wallet-snap/src/use-cases/AssetsUseCases.test.ts delete mode 100644 packages/bitcoin-wallet-snap/src/use-cases/AssetsUseCases.ts diff --git a/packages/bitcoin-wallet-snap/CHANGELOG.md b/packages/bitcoin-wallet-snap/CHANGELOG.md index 0d8a862e0..a7d346814 100644 --- a/packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/packages/bitcoin-wallet-snap/CHANGELOG.md @@ -19,6 +19,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Process the entire requested account range as a single batch instead of chunks of 100, so the existing-accounts lookup and state I/O happen once per request ([#221](https://github.com/MetaMask/internal-snaps/pull/221)) - Split the chain `stopGap` configuration into `{ discovery: 5, scan: 20 }` so account discovery keeps the cheap probe while full account scans use the BIP44 gap limit ([#224](https://github.com/MetaMask/internal-snaps/pull/224)) +### Removed + +- **BREAKING** Remove the `onAssetsLookup`, `onAssetsConversion`, `onAssetHistoricalPrice`, and `onAssetsMarketData` asset handler entry points, along with the now-unused `AssetsHandler`, `AssetsUseCases`, `InMemoryCache`, `ICache`, and the `endowment:assets` permission ([#260](https://github.com/MetaMask/internal-snaps/pull/260)) + ### Fixed - Coalesce concurrent account synchronization runs so stacked triggers (the 30s cronjob, `onActive`, and background events scheduled by `setSelectedAccounts`) share one run instead of duplicating network fetches, state writes, and keyring events ([#221](https://github.com/MetaMask/internal-snaps/pull/221)) diff --git a/packages/bitcoin-wallet-snap/jest.config.mjs b/packages/bitcoin-wallet-snap/jest.config.mjs index 6448641e0..0bbb8e9e8 100644 --- a/packages/bitcoin-wallet-snap/jest.config.mjs +++ b/packages/bitcoin-wallet-snap/jest.config.mjs @@ -24,10 +24,10 @@ const config = { // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { - branches: 65.5, - functions: 61.63, - lines: 75.29, - statements: 74.57, + branches: 72.43, + functions: 57.43, + lines: 81.02, + statements: 80.75, }, }, diff --git a/packages/bitcoin-wallet-snap/snap.manifest.json b/packages/bitcoin-wallet-snap/snap.manifest.json index 3fd7c0f9d..06b72be4c 100644 --- a/packages/bitcoin-wallet-snap/snap.manifest.json +++ b/packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/internal-snaps.git" }, "source": { - "shasum": "N40X9yuzuO3nWtRwrVRhAMQC1+XOcB3Ay3sxYrg2YFA=", + "shasum": "aQ6HjS1SyYkQjWSZl0+gv/5lfVJvR0rMbiSB8SijTLo=", "location": { "npm": { "filePath": "dist/bundle.js", @@ -99,15 +99,6 @@ } } ] - }, - "endowment:assets": { - "scopes": [ - "bip122:000000000019d6689c085ae165831e93", - "bip122:000000000933ea01ad0ee984209779ba", - "bip122:00000000da84f2bafbbc53dee25a72ae", - "bip122:00000008819873e925422c1ff0f99f7c", - "bip122:regtest" - ] } }, "platformVersion": "12.0.1", diff --git a/packages/bitcoin-wallet-snap/src/config.ts b/packages/bitcoin-wallet-snap/src/config.ts index 614013b24..9934764d5 100644 --- a/packages/bitcoin-wallet-snap/src/config.ts +++ b/packages/bitcoin-wallet-snap/src/config.ts @@ -60,6 +60,5 @@ export const Config: SnapConfig = { priceApi: { url: fromEnv('PRICE_API_URL', 'https://price.api.cx.metamask.io'), }, - conversionsExpirationInterval: 60, defaultAddressType: fromEnv('DEFAULT_ADDRESS_TYPE', 'p2wpkh') as AddressType, }; diff --git a/packages/bitcoin-wallet-snap/src/entities/config.ts b/packages/bitcoin-wallet-snap/src/entities/config.ts index ad2c50b68..1e5e7271a 100644 --- a/packages/bitcoin-wallet-snap/src/entities/config.ts +++ b/packages/bitcoin-wallet-snap/src/entities/config.ts @@ -9,7 +9,6 @@ export type SnapConfig = { fallbackFeeRate: number; ratesRefreshInterval: string; priceApi: PriceApiConfig; - conversionsExpirationInterval: number; defaultAddressType: AddressType; }; diff --git a/packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.test.ts b/packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.test.ts deleted file mode 100644 index 63cc40504..000000000 --- a/packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.test.ts +++ /dev/null @@ -1,237 +0,0 @@ -import type { - HistoricalPriceIntervals, - FungibleAssetMarketData, -} from '@metamask/snaps-sdk'; -import { mock } from 'jest-mock-extended'; - -import type { Logger, SnapClient, SpotPrice } from '../entities'; -import type { AssetsUseCases } from '../use-cases'; -import { AssetsHandler } from './AssetsHandler'; -import { Caip19Asset } from './caip'; - -describe('AssetsHandler', () => { - const mockAssetsUseCases = mock(); - const mockLogger = mock(); - const expirationInterval = 60; - const mockSnapClient = mock(); - - let handler: AssetsHandler; - - beforeEach(() => { - handler = new AssetsHandler( - mockAssetsUseCases, - expirationInterval, - mockLogger, - mockSnapClient, - ); - }); - - describe('lookup', () => { - it('returns data for all networks', async () => { - const result = await handler.lookup(); - - expect(result.assets[Caip19Asset.Bitcoin]?.name).toBe('Bitcoin'); - expect(result.assets[Caip19Asset.Testnet]?.name).toBe('Testnet Bitcoin'); - expect(result.assets[Caip19Asset.Testnet4]?.name).toBe( - 'Testnet4 Bitcoin', - ); - expect(result.assets[Caip19Asset.Signet]?.name).toBe('Signet Bitcoin'); - expect(result.assets[Caip19Asset.Regtest]?.name).toBe('Regtest Bitcoin'); - }); - }); - - describe('conversion', () => { - it('returns rates for all networks successfully', async () => { - mockAssetsUseCases.getRates.mockResolvedValue([ - [Caip19Asset.Testnet, mock({ price: 0.1 })], - [Caip19Asset.Regtest, mock({ price: 0.2 })], - ]); - - const conversions = [ - { from: Caip19Asset.Bitcoin, to: Caip19Asset.Testnet }, - { from: Caip19Asset.Bitcoin, to: Caip19Asset.Regtest }, - { from: Caip19Asset.Testnet, to: Caip19Asset.Bitcoin }, - { from: Caip19Asset.Testnet4, to: Caip19Asset.Bitcoin }, - { from: Caip19Asset.Signet, to: Caip19Asset.Bitcoin }, - { from: Caip19Asset.Regtest, to: Caip19Asset.Bitcoin }, - ]; - const result = await handler.conversion(conversions); - - expect(mockAssetsUseCases.getRates).toHaveBeenCalledTimes(1); - expect(mockAssetsUseCases.getRates).toHaveBeenCalledWith([ - Caip19Asset.Testnet, - Caip19Asset.Regtest, - ]); - expect(result.conversionRates).toStrictEqual({ - [Caip19Asset.Bitcoin]: { - [Caip19Asset.Testnet]: { - rate: '0.1', - conversionTime: expect.any(Number), - expirationTime: expect.any(Number), - }, - [Caip19Asset.Regtest]: { - rate: '0.2', - conversionTime: expect.any(Number), - expirationTime: expect.any(Number), - }, - }, - [Caip19Asset.Testnet]: { - [Caip19Asset.Bitcoin]: { - rate: '0', - conversionTime: expect.any(Number), - expirationTime: expect.any(Number), - }, - }, - [Caip19Asset.Testnet4]: { - [Caip19Asset.Bitcoin]: { - rate: '0', - conversionTime: expect.any(Number), - expirationTime: expect.any(Number), - }, - }, - [Caip19Asset.Signet]: { - [Caip19Asset.Bitcoin]: { - rate: '0', - conversionTime: expect.any(Number), - expirationTime: expect.any(Number), - }, - }, - [Caip19Asset.Regtest]: { - [Caip19Asset.Bitcoin]: { - rate: '0', - conversionTime: expect.any(Number), - expirationTime: expect.any(Number), - }, - }, - }); - }); - - it('handles null rates gracefully when getRates returns null', async () => { - const conversions = [ - { from: Caip19Asset.Bitcoin, to: Caip19Asset.Testnet }, - ]; - mockAssetsUseCases.getRates.mockResolvedValue([ - [Caip19Asset.Testnet, null], - ]); - - const result = await handler.conversion(conversions); - - expect(result.conversionRates).toStrictEqual({ - [Caip19Asset.Bitcoin]: { - [Caip19Asset.Testnet]: null, - }, - }); - }); - }); - - describe('historicalPrice', () => { - it('returns null if from is not Bitcoin', async () => { - const result = await handler.historicalPrice( - Caip19Asset.Testnet, - Caip19Asset.Bitcoin, - ); - expect(result).toBeNull(); - }); - - it('returns prices for Bitcoin successfully', async () => { - const mockIntervals = mock(); - mockAssetsUseCases.getPriceIntervals.mockResolvedValue(mockIntervals); - - const result = await handler.historicalPrice( - Caip19Asset.Bitcoin, - Caip19Asset.Testnet, - ); - - expect(mockAssetsUseCases.getPriceIntervals).toHaveBeenCalledWith( - Caip19Asset.Testnet, - ); - expect(result?.historicalPrice.intervals).toStrictEqual(mockIntervals); - }); - - it('returns null, tracks the error, and logs warning when getPriceIntervals fails', async () => { - const error = new Error('getPriceIntervals failed'); - mockAssetsUseCases.getPriceIntervals.mockRejectedValue(error); - - const result = await handler.historicalPrice( - Caip19Asset.Bitcoin, - Caip19Asset.Testnet, - ); - - expect(mockSnapClient.emitTrackingError).toHaveBeenCalledWith(error); - expect(mockLogger.warn).toHaveBeenCalledWith( - 'Failed to fetch historical prices from %s to %s. Error: %s', - Caip19Asset.Bitcoin, - Caip19Asset.Testnet, - error, - ); - expect(result).toBeNull(); - }); - }); - - describe('marketData', () => { - it('returns market data for all assets successfully', async () => { - const mockMarketData = mock(); - mockAssetsUseCases.getRates.mockResolvedValue([ - [ - Caip19Asset.Testnet, - mock({ price: 0.1, marketData: mockMarketData }), - ], - [ - Caip19Asset.Regtest, - mock({ price: 0.2, marketData: mockMarketData }), - ], - ]); - - const assets = [ - { asset: Caip19Asset.Bitcoin, unit: Caip19Asset.Testnet }, - { asset: Caip19Asset.Bitcoin, unit: Caip19Asset.Regtest }, - { asset: Caip19Asset.Testnet, unit: Caip19Asset.Bitcoin }, - { asset: Caip19Asset.Testnet4, unit: Caip19Asset.Bitcoin }, - { asset: Caip19Asset.Signet, unit: Caip19Asset.Bitcoin }, - { asset: Caip19Asset.Regtest, unit: Caip19Asset.Bitcoin }, - ]; - const result = await handler.marketData(assets); - - expect(mockAssetsUseCases.getRates).toHaveBeenCalledTimes(1); - expect(mockAssetsUseCases.getRates).toHaveBeenCalledWith([ - Caip19Asset.Testnet, - Caip19Asset.Regtest, - ]); - expect(result.marketData).toStrictEqual({ - [Caip19Asset.Bitcoin]: { - [Caip19Asset.Testnet]: mockMarketData, - [Caip19Asset.Regtest]: mockMarketData, - }, - [Caip19Asset.Testnet]: { - [Caip19Asset.Bitcoin]: null, - }, - [Caip19Asset.Testnet4]: { - [Caip19Asset.Bitcoin]: null, - }, - [Caip19Asset.Signet]: { - [Caip19Asset.Bitcoin]: null, - }, - [Caip19Asset.Regtest]: { - [Caip19Asset.Bitcoin]: null, - }, - }); - }); - - it('handles null rates gracefully when getRates returns null', async () => { - const assets = [ - { asset: Caip19Asset.Bitcoin, unit: Caip19Asset.Testnet }, - ]; - mockAssetsUseCases.getRates.mockResolvedValue([ - [Caip19Asset.Testnet, null], - ]); - - const result = await handler.marketData(assets); - - expect(result.marketData).toStrictEqual({ - [Caip19Asset.Bitcoin]: { - [Caip19Asset.Testnet]: null, - }, - }); - }); - }); -}); diff --git a/packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.ts b/packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.ts deleted file mode 100644 index 50f3ff745..000000000 --- a/packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.ts +++ /dev/null @@ -1,215 +0,0 @@ -import type { Network } from '@metamask/bitcoindevkit'; -import { getCurrentUnixTimestamp } from '@metamask/keyring-snap-sdk'; -import type { - CaipAssetType, - FungibleAssetMetadata, - OnAssetHistoricalPriceResponse, - OnAssetsConversionArguments, - OnAssetsConversionResponse, - OnAssetsLookupResponse, - OnAssetsMarketDataArguments, - OnAssetsMarketDataResponse, -} from '@metamask/snaps-sdk'; -import { CaipAssetTypeStruct } from '@metamask/utils'; -import { assert } from 'superstruct'; - -import type { Logger, SnapClient } from '../entities'; -import type { AssetsUseCases } from '../use-cases'; -import { Caip19Asset } from './caip'; -import { networkToIcon } from './icons'; - -export class AssetsHandler { - readonly #assetsUseCases: AssetsUseCases; - - readonly #expirationInterval: number; - - readonly #logger: Logger; - - readonly #snapClient: SnapClient; - - constructor( - assets: AssetsUseCases, - expirationInterval: number, - logger: Logger, - snapClient: SnapClient, - ) { - this.#assetsUseCases = assets; - this.#expirationInterval = expirationInterval; - this.#logger = logger; - this.#snapClient = snapClient; - } - - async lookup(): Promise { - // Static function that cannot fail so no need to use handle() - - const metadata = ( - network: Network, - name: string, - mainSymbol: string, - ): FungibleAssetMetadata => { - return { - fungible: true, - name, - units: [ - { - name: 'Bitcoin', - decimals: 8, - symbol: mainSymbol, - }, - { - name: 'CentiBitcoin', - decimals: 6, - symbol: 'cBTC', - }, - { - name: 'MilliBitcoin', - decimals: 5, - symbol: 'mBTC', - }, - { - name: 'Bit', - decimals: 2, - symbol: 'bits', - }, - { - name: 'Satoshi', - decimals: 0, - symbol: 'satoshi', - }, - ], - iconUrl: networkToIcon[network], - symbol: mainSymbol, - }; - }; - - // Use the same denominations as Bitcoin for testnets but change the name and main unit symbol - return { - assets: { - [Caip19Asset.Bitcoin]: metadata('bitcoin', 'Bitcoin', 'BTC'), - [Caip19Asset.Testnet]: metadata('testnet', 'Testnet Bitcoin', 'tBTC'), - [Caip19Asset.Testnet4]: metadata( - 'testnet4', - 'Testnet4 Bitcoin', - 'tBTC', - ), - [Caip19Asset.Signet]: metadata('signet', 'Signet Bitcoin', 'sBTC'), - [Caip19Asset.Regtest]: metadata('regtest', 'Regtest Bitcoin', 'rBTC'), - }, - }; - } - - async conversion( - conversions: OnAssetsConversionArguments['conversions'], - ): Promise { - const conversionTime = getCurrentUnixTimestamp(); - - // Group conversions by "from" - const assetMap: Record = {}; - for (const { from, to } of conversions) { - assetMap[from] ??= []; - assetMap[from].push(to); - } - - const conversionRates: OnAssetsConversionResponse['conversionRates'] = {}; - - for (const [fromAsset, toAssets] of Object.entries(assetMap)) { - const fromKey = fromAsset as keyof typeof conversionRates; - conversionRates[fromKey] = {}; - - if (fromKey === (Caip19Asset.Bitcoin as CaipAssetType)) { - // For Bitcoin, fetch rates. - for (const [toAsset, rate] of await this.#assetsUseCases.getRates( - toAssets, - )) { - conversionRates[fromKey][toAsset] = rate - ? { - rate: rate.price.toString(), - conversionTime, - expirationTime: conversionTime + this.#expirationInterval, - } - : null; - } - } else { - // For every other conversions, we just use a rate of 0. - for (const toAsset of toAssets) { - conversionRates[fromKey][toAsset] = { - rate: '0', - conversionTime, - expirationTime: conversionTime + 60 * 60 * 24, // Long expiration time (1 day) to avoid unnecessary requests - }; - } - } - } - - return { conversionRates }; - } - - async historicalPrice( - from: CaipAssetType, - to: CaipAssetType, - ): Promise { - assert(from, CaipAssetTypeStruct); - assert(to, CaipAssetTypeStruct); - - if (from !== (Caip19Asset.Bitcoin as CaipAssetType)) { - return null; - } - - try { - const updateTime = getCurrentUnixTimestamp(); - const intervals = await this.#assetsUseCases.getPriceIntervals(to); - - return { - historicalPrice: { - intervals, - updateTime, - expirationTime: updateTime + this.#expirationInterval, - }, - }; - } catch (error) { - await this.#snapClient.emitTrackingError(error as Error); - - this.#logger.warn( - 'Failed to fetch historical prices from %s to %s. Error: %s', - from, - to, - error, - ); - return null; - } - } - - async marketData( - assets: OnAssetsMarketDataArguments['assets'], - ): Promise { - // Group market data by "asset" - const assetMap: Record = {}; - for (const { asset, unit } of assets) { - assetMap[asset] ??= []; - assetMap[asset].push(unit); - } - - const marketData: OnAssetsMarketDataResponse['marketData'] = {}; - - for (const [fromAsset, toAssets] of Object.entries(assetMap)) { - const fromKey = fromAsset as keyof typeof marketData; - marketData[fromKey] = {}; - - if (fromKey === (Caip19Asset.Bitcoin as CaipAssetType)) { - // For Bitcoin, fetch market data. - for (const [toAsset, rate] of await this.#assetsUseCases.getRates( - toAssets, - )) { - marketData[fromKey][toAsset] = rate ? rate.marketData : null; - } - } else { - // For every other assets, there is no market data. - for (const toAsset of toAssets) { - marketData[fromKey][toAsset] = null; - } - } - } - - return { marketData }; - } -} diff --git a/packages/bitcoin-wallet-snap/src/handlers/index.ts b/packages/bitcoin-wallet-snap/src/handlers/index.ts index ccde4f447..8eb7fe3d5 100644 --- a/packages/bitcoin-wallet-snap/src/handlers/index.ts +++ b/packages/bitcoin-wallet-snap/src/handlers/index.ts @@ -2,5 +2,4 @@ export * from './KeyringHandler'; export * from './CronHandler'; export * from './RpcHandler'; export * from './UserInputHandler'; -export * from './AssetsHandler'; export * from './caip'; diff --git a/packages/bitcoin-wallet-snap/src/index.ts b/packages/bitcoin-wallet-snap/src/index.ts index c0e6036ce..f55d5a14b 100644 --- a/packages/bitcoin-wallet-snap/src/index.ts +++ b/packages/bitcoin-wallet-snap/src/index.ts @@ -1,12 +1,8 @@ import { handleKeyringRequest } from '@metamask/keyring-snap-sdk/v2'; import type { - OnAssetsConversionHandler, - OnAssetsLookupHandler, OnCronjobHandler, OnKeyringRequestHandler, OnUserInputHandler, - OnAssetHistoricalPriceHandler, - OnAssetsMarketDataHandler, OnClientRequestHandler, OnActiveHandler, } from '@metamask/snaps-sdk'; @@ -17,7 +13,6 @@ import { CronHandler, UserInputHandler, RpcHandler, - AssetsHandler, } from './handlers'; import { HandlerMiddleware } from './handlers/HandlerMiddleware'; import { KeyringRequestHandler } from './handlers/KeyringRequestHandler'; @@ -28,11 +23,9 @@ import { LocalTranslatorAdapter, } from './infra'; import { BdkAccountRepository, JSXSendFlowRepository } from './store'; -import { InMemoryCache } from './store/InMemoryCache'; import { JSXConfirmationRepository } from './store/JSXConfirmationRepository'; import { AccountUseCases, - AssetsUseCases, ConfirmationUseCases, SendFlowUseCases, } from './use-cases'; @@ -78,12 +71,6 @@ const sendFlowUseCases = new SendFlowUseCases( Config.fallbackFeeRate, Config.ratesRefreshInterval, ); -const assetsUseCases = new AssetsUseCases( - logger, - assetRatesClient, - new InMemoryCache(), - snapClient, -); const confirmationUseCases = new ConfirmationUseCases(logger, snapClient); // Application layer @@ -109,12 +96,6 @@ const userInputHandler = new UserInputHandler( sendFlowUseCases, confirmationUseCases, ); -const assetsHandler = new AssetsHandler( - assetsUseCases, - Config.conversionsExpirationInterval, - logger, - snapClient, -); export const onCronjob: OnCronjobHandler = async ({ request }) => middleware.handle(async () => cronHandler.route(request)); @@ -130,21 +111,5 @@ export const onKeyringRequest: OnKeyringRequestHandler = async ({ request }) => export const onUserInput: OnUserInputHandler = async ({ id, event, context }) => middleware.handle(async () => userInputHandler.route(id, event, context)); -export const onAssetsLookup: OnAssetsLookupHandler = async () => - middleware.handle(async () => assetsHandler.lookup()); - -export const onAssetsConversion: OnAssetsConversionHandler = async ({ - conversions, -}) => middleware.handle(async () => assetsHandler.conversion(conversions)); - -export const onAssetHistoricalPrice: OnAssetHistoricalPriceHandler = async ({ - from, - to, -}) => middleware.handle(async () => assetsHandler.historicalPrice(from, to)); - -export const onAssetsMarketData: OnAssetsMarketDataHandler = async ({ - assets, -}) => middleware.handle(async () => assetsHandler.marketData(assets)); - export const onActive: OnActiveHandler = async () => middleware.handle(async () => cronHandler.synchronizeAccounts()); diff --git a/packages/bitcoin-wallet-snap/src/store/ICache.ts b/packages/bitcoin-wallet-snap/src/store/ICache.ts deleted file mode 100644 index 3fea3ef86..000000000 --- a/packages/bitcoin-wallet-snap/src/store/ICache.ts +++ /dev/null @@ -1,124 +0,0 @@ -import type { Json } from '@metamask/snaps-sdk'; - -/** - * A primitive value that can be serialized to JSON using the `serialize` function. - */ -export type Serializable = - | Json - | undefined - | null - | bigint - | BigNumber - | Uint8Array - | Serializable[] - | { - [prop: string]: Serializable; - }; - -export type TimestampMilliseconds = number; - -/** - * A single cache entry. - */ -export type CacheEntry = { - value: Serializable; - expiresAt: TimestampMilliseconds; -}; - -/** - * Interface for a generic cache implementation. - * - * @template TValue - The type of values stored in the cache - */ -export type ICache = { - /** - * Retrieves a value from the cache by key. - * - * @param key - The key to retrieve - * @returns The value if found, undefined if not found - */ - get(key: string): Promise; - - /** - * Stores a value in the cache with an optional TTL. - * - If a value is undefined, it will not be stored in the cache. - * - If a value is null, it will be stored in the cache. - * - * @param key - The key to store the value under - * @param value - The value to store - * @param ttlMilliseconds - Optional time-to-live in milliseconds. If not provided, the value will not expire. - * @throws Error if any entry's ttlMilliseconds is not a number, is negative, or is greater than 2^53 - 1 - */ - set(key: string, value: TValue, ttlMilliseconds?: number): Promise; - - /** - * Removes a value from the cache. - * - * @param key - The key to remove - * @returns true if the key was found and removed, false otherwise - */ - delete(key: string): Promise; - - /** - * Removes all values from the cache. - */ - clear(): Promise; - - /** - * Checks if a key exists in the cache. - * - * @param key - The key to check - * @returns true if the key exists, false otherwise - */ - has(key: string): Promise; - - /** - * Returns all keys currently in the cache. - * - * @returns Array of keys - */ - keys(): Promise; - - /** - * Returns the number of items in the cache. - * - * @returns The number of items - */ - size(): Promise; - - /** - * Retrieves a value from the cache without affecting its TTL or last accessed time. - * - * @param key - The key to peek at - * @returns The value if found, undefined if not found - */ - peek(key: string): Promise; - - /** - * Retrieves multiple values from the cache in a single operation. - * - * @param keys - Array of keys to retrieve - * @returns Object mapping keys to their values (or undefined if not found) - */ - mget(keys: string[]): Promise>; - - /** - * Stores multiple values in the cache in a single operation. - * - If a value is undefined, it will not be stored in the cache. - * - If a value is null, it will be stored in the cache. - * - * @param entries - Array of entries to store, each with key, value, and optional TTL (if not provided, the value will not expire) - * @throws Error if any entry's ttlMilliseconds is not a number, is negative, or is greater than 2^53 - 1 - */ - mset( - entries: { key: string; value: TValue; ttlMilliseconds?: number }[], - ): Promise; - - /** - * Removes multiple values from the cache. - * - * @param keys - Array of keys to remove - * @returns An object mapping each key to a boolean indicating whether it was found and removed - */ - mdelete(keys: string[]): Promise>; -}; diff --git a/packages/bitcoin-wallet-snap/src/store/InMemoryCache.test.ts b/packages/bitcoin-wallet-snap/src/store/InMemoryCache.test.ts deleted file mode 100644 index a0e40d5f9..000000000 --- a/packages/bitcoin-wallet-snap/src/store/InMemoryCache.test.ts +++ /dev/null @@ -1,528 +0,0 @@ -import { InMemoryCache } from './InMemoryCache'; - -describe('InMemoryCache', () => { - let cache: InMemoryCache; - - beforeEach(() => { - cache = new InMemoryCache(); - jest.clearAllMocks(); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - describe('set and get', () => { - it('stores and retrieves a value', async () => { - await cache.set('key1', 'value1'); - const result = await cache.get('key1'); - - expect(result).toBe('value1'); - }); - - it('stores and retrieves complex objects', async () => { - const complexObject = { - nested: { data: [1, 2, 3] }, - string: 'test', - number: 42, - }; - - await cache.set('complex', complexObject); - const result = await cache.get('complex'); - - expect(result).toStrictEqual(complexObject); - }); - - it('stores null values', async () => { - await cache.set('null-key', null); - const result = await cache.get('null-key'); - - expect(result).toBeNull(); - }); - - it('returns undefined for non-existent keys', async () => { - const result = await cache.get('non-existent'); - - expect(result).toBeUndefined(); - }); - - it('overwrites existing values', async () => { - await cache.set('key1', 'value1'); - await cache.set('key1', 'value2'); - const result = await cache.get('key1'); - - expect(result).toBe('value2'); - }); - }); - - describe('TTL (Time To Live)', () => { - it('stores value with custom TTL', async () => { - await cache.set('key1', 'value1', 1000); - const result = await cache.get('key1'); - - expect(result).toBe('value1'); - }); - - it('expires value after TTL', async () => { - jest.useFakeTimers(); - const ttl = 1000; - - await cache.set('key1', 'value1', ttl); - - // Before expiration - expect(await cache.get('key1')).toBe('value1'); - - // After expiration - jest.advanceTimersByTime(ttl + 1); - expect(await cache.get('key1')).toBeUndefined(); - }); - - it('uses default TTL when not specified', async () => { - await cache.set('key1', 'value1'); - const result = await cache.get('key1'); - - expect(result).toBe('value1'); - }); - - it('throws error for negative TTL', async () => { - await expect(cache.set('key1', 'value1', -100)).rejects.toThrow( - 'TTL must be positive', - ); - }); - - it('throws error for non-numeric TTL', async () => { - await expect( - cache.set('key1', 'value1', 'invalid' as any), - ).rejects.toThrow('TTL must be a number'); - }); - - it('throws error for TTL greater than MAX_SAFE_INTEGER', async () => { - await expect( - cache.set('key1', 'value1', Number.MAX_SAFE_INTEGER + 1), - ).rejects.toThrow('TTL must be less than 2^53 - 1'); - }); - - it('handles TTL of zero', async () => { - jest.useFakeTimers(); - - await cache.set('key1', 'value1', 0); - - // Should be immediately expired - jest.advanceTimersByTime(1); - expect(await cache.get('key1')).toBeUndefined(); - }); - - it('clamps TTL to MAX_SAFE_INTEGER to prevent overflow', async () => { - const largeButValidTTL = Number.MAX_SAFE_INTEGER; - await cache.set('key1', 'value1', largeButValidTTL); - const result = await cache.get('key1'); - - expect(result).toBe('value1'); - }); - }); - - describe('delete', () => { - it('deletes an existing key', async () => { - await cache.set('key1', 'value1'); - const deleted = await cache.delete('key1'); - - expect(deleted).toBe(true); - expect(await cache.get('key1')).toBeUndefined(); - }); - - it('returns false when deleting non-existent key', async () => { - const deleted = await cache.delete('non-existent'); - - expect(deleted).toBe(false); - }); - }); - - describe('has', () => { - it('returns true for existing keys', async () => { - await cache.set('key1', 'value1'); - - expect(await cache.has('key1')).toBe(true); - }); - - it('returns false for non-existent keys', async () => { - expect(await cache.has('non-existent')).toBe(false); - }); - - it('returns false and removes expired keys', async () => { - jest.useFakeTimers(); - await cache.set('key1', 'value1', 1000); - - expect(await cache.has('key1')).toBe(true); - - jest.advanceTimersByTime(1001); - - expect(await cache.has('key1')).toBe(false); - // Verify the key was actually removed - expect(await cache.get('key1')).toBeUndefined(); - }); - }); - - describe('clear', () => { - it('removes all entries', async () => { - await cache.set('key1', 'value1'); - await cache.set('key2', 'value2'); - await cache.set('key3', 'value3'); - - await cache.clear(); - - expect(await cache.get('key1')).toBeUndefined(); - expect(await cache.get('key2')).toBeUndefined(); - expect(await cache.get('key3')).toBeUndefined(); - expect(await cache.size()).toBe(0); - }); - - it('works on empty cache', async () => { - await cache.clear(); - expect(await cache.size()).toBe(0); - }); - }); - - describe('keys', () => { - it('returns all keys', async () => { - await cache.set('key1', 'value1'); - await cache.set('key2', 'value2'); - await cache.set('key3', 'value3'); - - const keys = await cache.keys(); - - expect(keys).toHaveLength(3); - expect(keys).toContain('key1'); - expect(keys).toContain('key2'); - expect(keys).toContain('key3'); - }); - - it('returns empty array for empty cache', async () => { - const keys = await cache.keys(); - - expect(keys).toStrictEqual([]); - }); - - it('excludes expired keys', async () => { - jest.useFakeTimers(); - await cache.set('key1', 'value1', 1000); - await cache.set('key2', 'value2', 2000); - await cache.set('key3', 'value3', 3000); - - jest.advanceTimersByTime(1500); - - const keys = await cache.keys(); - - expect(keys).toHaveLength(2); - expect(keys).not.toContain('key1'); - expect(keys).toContain('key2'); - expect(keys).toContain('key3'); - }); - }); - - describe('size', () => { - it('returns the number of entries', async () => { - expect(await cache.size()).toBe(0); - - await cache.set('key1', 'value1'); - expect(await cache.size()).toBe(1); - - await cache.set('key2', 'value2'); - expect(await cache.size()).toBe(2); - - await cache.delete('key1'); - expect(await cache.size()).toBe(1); - }); - - it('excludes expired entries', async () => { - jest.useFakeTimers(); - await cache.set('key1', 'value1', 1000); - await cache.set('key2', 'value2', 2000); - - expect(await cache.size()).toBe(2); - - jest.advanceTimersByTime(1500); - - expect(await cache.size()).toBe(1); - }); - }); - - describe('peek', () => { - it('retrieves value without affecting TTL', async () => { - await cache.set('key1', 'value1'); - const result = await cache.peek('key1'); - - expect(result).toBe('value1'); - }); - - it('returns undefined for non-existent keys', async () => { - const result = await cache.peek('non-existent'); - - expect(result).toBeUndefined(); - }); - - it('returns undefined and removes expired keys', async () => { - jest.useFakeTimers(); - await cache.set('key1', 'value1', 1000); - - expect(await cache.peek('key1')).toBe('value1'); - - jest.advanceTimersByTime(1001); - - expect(await cache.peek('key1')).toBeUndefined(); - // Verify the key was actually removed - expect(await cache.get('key1')).toBeUndefined(); - }); - }); - - describe('mget', () => { - it('retrieves multiple values', async () => { - await cache.set('key1', 'value1'); - await cache.set('key2', 'value2'); - await cache.set('key3', 'value3'); - - const result = await cache.mget(['key1', 'key2', 'key3']); - - expect(result).toStrictEqual({ - key1: 'value1', - key2: 'value2', - key3: 'value3', - }); - }); - - it('returns undefined for non-existent keys', async () => { - await cache.set('key1', 'value1'); - - const result = await cache.mget(['key1', 'key2', 'key3']); - - expect(result).toStrictEqual({ - key1: 'value1', - key2: undefined, - key3: undefined, - }); - }); - - it('handles empty key array', async () => { - const result = await cache.mget([]); - - expect(result).toStrictEqual({}); - }); - - it('excludes expired entries', async () => { - jest.useFakeTimers(); - await cache.set('key1', 'value1', 1000); - await cache.set('key2', 'value2', 2000); - - jest.advanceTimersByTime(1500); - - const result = await cache.mget(['key1', 'key2']); - - expect(result).toStrictEqual({ - key1: undefined, - key2: 'value2', - }); - }); - }); - - describe('mset', () => { - it('stores multiple values', async () => { - await cache.mset([ - { key: 'key1', value: 'value1' }, - { key: 'key2', value: 'value2' }, - { key: 'key3', value: 'value3' }, - ]); - - expect(await cache.get('key1')).toBe('value1'); - expect(await cache.get('key2')).toBe('value2'); - expect(await cache.get('key3')).toBe('value3'); - }); - - it('stores multiple values with different TTLs', async () => { - jest.useFakeTimers(); - await cache.mset([ - { key: 'key1', value: 'value1', ttlMilliseconds: 1000 }, - { key: 'key2', value: 'value2', ttlMilliseconds: 2000 }, - ]); - - jest.advanceTimersByTime(1500); - - expect(await cache.get('key1')).toBeUndefined(); - expect(await cache.get('key2')).toBe('value2'); - }); - - it('skips undefined values', async () => { - await cache.mset([ - { key: 'key1', value: 'value1' }, - { key: 'key2', value: undefined }, - { key: 'key3', value: 'value3' }, - ]); - - expect(await cache.get('key1')).toBe('value1'); - expect(await cache.get('key2')).toBeUndefined(); - expect(await cache.get('key3')).toBe('value3'); - expect(await cache.size()).toBe(2); - }); - - it('stores null values', async () => { - await cache.mset([{ key: 'key1', value: null }]); - - expect(await cache.get('key1')).toBeNull(); - }); - - it('handles empty array', async () => { - await cache.mset([]); - expect(await cache.size()).toBe(0); - }); - - it('handles single entry (delegates to set)', async () => { - await cache.mset([ - { key: 'key1', value: 'value1', ttlMilliseconds: 1000 }, - ]); - - expect(await cache.get('key1')).toBe('value1'); - }); - - it('throws error if any TTL is invalid', async () => { - await expect( - cache.mset([ - { key: 'key1', value: 'value1', ttlMilliseconds: 1000 }, - { key: 'key2', value: 'value2', ttlMilliseconds: -100 }, - ]), - ).rejects.toThrow('TTL must be positive'); - - // Verify no values were set - expect(await cache.get('key1')).toBeUndefined(); - expect(await cache.get('key2')).toBeUndefined(); - }); - }); - - describe('mdelete', () => { - it('deletes multiple keys', async () => { - await cache.set('key1', 'value1'); - await cache.set('key2', 'value2'); - await cache.set('key3', 'value3'); - - const result = await cache.mdelete(['key1', 'key2']); - - expect(result).toStrictEqual({ - key1: true, - key2: true, - }); - expect(await cache.get('key1')).toBeUndefined(); - expect(await cache.get('key2')).toBeUndefined(); - expect(await cache.get('key3')).toBe('value3'); - }); - - it('returns false for non-existent keys', async () => { - await cache.set('key1', 'value1'); - - const result = await cache.mdelete(['key1', 'key2', 'key3']); - - expect(result).toStrictEqual({ - key1: true, - key2: false, - key3: false, - }); - }); - - it('handles empty array', async () => { - const result = await cache.mdelete([]); - - expect(result).toStrictEqual({}); - }); - }); - - describe('edge cases and integration', () => { - it('handles rapid successive operations', async () => { - await cache.set('key1', 'value1'); - await cache.set('key1', 'value2'); - await cache.set('key1', 'value3'); - - expect(await cache.get('key1')).toBe('value3'); - }); - - it('handles large number of entries', async () => { - const entries = Array.from({ length: 1000 }, (_, i) => ({ - key: `key${i}`, - value: `value${i}`, - })); - - await cache.mset(entries); - - expect(await cache.size()).toBe(1000); - expect(await cache.get('key500')).toBe('value500'); - }); - - it('maintains separate entries for similar keys', async () => { - await cache.set('key', 'value1'); - await cache.set('key1', 'value2'); - await cache.set('key10', 'value3'); - - expect(await cache.get('key')).toBe('value1'); - expect(await cache.get('key1')).toBe('value2'); - expect(await cache.get('key10')).toBe('value3'); - }); - - it('handles mixed operations with expiration', async () => { - jest.useFakeTimers(); - - await cache.set('key1', 'value1', 1000); - await cache.set('key2', 'value2', 2000); - await cache.set('key3', 'value3'); - - jest.advanceTimersByTime(1500); - - await cache.delete('key3'); - await cache.set('key4', 'value4'); - - expect(await cache.size()).toBe(2); // key2 and key4 - expect(await cache.keys()).toStrictEqual(['key2', 'key4']); - }); - - it('handles special characters in keys', async () => { - const specialKeys = [ - 'key:with:colons', - 'key.with.dots', - 'key-with-dashes', - 'key_with_underscores', - 'key with spaces', - 'key/with/slashes', - ]; - - for (const key of specialKeys) { - await cache.set(key, `value-${key}`); - } - - for (const key of specialKeys) { - expect(await cache.get(key)).toBe(`value-${key}`); - } - }); - - it('handles bigint values', async () => { - const bigIntValue = BigInt(9007199254740991); - await cache.set('bigint', bigIntValue); - - expect(await cache.get('bigint')).toBe(bigIntValue); - }); - - it('handles Uint8Array values', async () => { - const uint8Array = new Uint8Array([1, 2, 3, 4, 5]); - await cache.set('uint8array', uint8Array); - - const result = await cache.get('uint8array'); - expect(result).toBeInstanceOf(Uint8Array); - expect(result).toStrictEqual(uint8Array); - }); - - it('handles nested arrays and objects', async () => { - const complexValue = { - array: [1, 2, { nested: 'value' }], - object: { a: 1, b: { c: 2 } }, - mixed: [{ x: 1 }, { y: 2 }], - }; - - await cache.set('complex', complexValue); - - expect(await cache.get('complex')).toStrictEqual(complexValue); - }); - }); -}); diff --git a/packages/bitcoin-wallet-snap/src/store/InMemoryCache.ts b/packages/bitcoin-wallet-snap/src/store/InMemoryCache.ts deleted file mode 100644 index 73657ce88..000000000 --- a/packages/bitcoin-wallet-snap/src/store/InMemoryCache.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { assert } from '@metamask/utils'; - -import type { CacheEntry, ICache, Serializable } from './ICache'; - -/** - * A simple in-memory cache implementation supporting TTL (Time To Live) functionality. - * - * WARNINGS: - * - This cache is not persistent and will be lost when the process is restarted. - */ -export class InMemoryCache implements ICache { - readonly #cache: Map = new Map(); - - #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'); - } - } - - #isExpired(cacheEntry: CacheEntry): boolean { - return cacheEntry.expiresAt < Date.now(); - } - - async #cleanupExpiredEntries(): Promise { - const expiredKeys: string[] = []; - for (const [key, entry] of this.#cache.entries()) { - if (this.#isExpired(entry)) { - expiredKeys.push(key); - } - } - await this.mdelete(expiredKeys); - } - - 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); - - this.#cache.set(key, { - value, - expiresAt: Math.min( - Date.now() + (ttlMilliseconds ?? Number.MAX_SAFE_INTEGER), - Number.MAX_SAFE_INTEGER, - ), - }); - } - - async delete(key: string): Promise { - const result = await this.mdelete([key]); - return result[key] ?? false; - } - - async clear(): Promise { - this.#cache.clear(); - } - - async has(key: string): Promise { - const cacheEntry = this.#cache.get(key); - if (!cacheEntry) { - return false; - } - - if (this.#isExpired(cacheEntry)) { - this.#cache.delete(key); - return false; - } - - return true; - } - - async keys(): Promise { - await this.#cleanupExpiredEntries(); - return Array.from(this.#cache.keys()); - } - - async size(): Promise { - await this.#cleanupExpiredEntries(); - return this.#cache.size; - } - - async peek(key: string): Promise { - const cacheEntry = this.#cache.get(key); - if (!cacheEntry) { - return undefined; - } - - if (this.#isExpired(cacheEntry)) { - this.#cache.delete(key); - return undefined; - } - - return cacheEntry.value; - } - - async mget( - keys: string[], - ): Promise> { - await this.#cleanupExpiredEntries(); - - const result: Record = {}; - - for (const key of keys) { - const cacheEntry = this.#cache.get(key); - if (!cacheEntry) { - result[key] = undefined; - continue; - } - - result[key] = cacheEntry.value; - } - - 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); - }); - - entries.forEach(({ key, value, ttlMilliseconds }) => { - if (value === undefined) { - return; - } - this.#cache.set(key, { - value, - expiresAt: Math.min( - Date.now() + (ttlMilliseconds ?? Number.MAX_SAFE_INTEGER), - Number.MAX_SAFE_INTEGER, - ), - }); - }); - } - - async mdelete(keys: string[]): Promise> { - return Object.fromEntries( - keys.map((key) => [key, this.#cache.delete(key)]), - ); - } -} diff --git a/packages/bitcoin-wallet-snap/src/use-cases/AssetsUseCases.test.ts b/packages/bitcoin-wallet-snap/src/use-cases/AssetsUseCases.test.ts deleted file mode 100644 index 17f116535..000000000 --- a/packages/bitcoin-wallet-snap/src/use-cases/AssetsUseCases.test.ts +++ /dev/null @@ -1,201 +0,0 @@ -import type { HistoricalPriceValue } from '@metamask/snaps-sdk'; -import { mock } from 'jest-mock-extended'; - -import type { - AssetRatesClient, - Logger, - SnapClient, - SpotPrice, -} from '../entities'; -import { Caip19Asset } from '../handlers/caip'; -import type { ICache, Serializable } from '../store/ICache'; -import { AssetsUseCases } from './AssetsUseCases'; - -describe('AssetsUseCases', () => { - const mockLogger = mock(); - const mockAssetRates = mock(); - const mockCache = mock>(); - const mockSnapClient = mock(); - - const useCases = new AssetsUseCases( - mockLogger, - mockAssetRates, - mockCache, - mockSnapClient, - ); - - describe('getBtcRates', () => { - it('returns rate for the known assets and null for unknown', async () => { - const mockExchangeRatesUSD = mock({ - price: 1, - marketData: { - allTimeHigh: '110000', - }, - }); - const mockExchangeRatesETH = mock({ - price: 1, - marketData: { - allTimeHigh: '0.1', - }, - }); - const mockExchangeRatesBTC = mock({ - price: 1, - marketData: { - allTimeHigh: '1', - }, - }); - - mockCache.get.mockResolvedValue(undefined); - mockAssetRates.spotPrices.mockResolvedValueOnce(mockExchangeRatesETH); - mockAssetRates.spotPrices.mockResolvedValueOnce(mockExchangeRatesBTC); - mockAssetRates.spotPrices.mockResolvedValueOnce(mockExchangeRatesUSD); - - const result = await useCases.getRates([ - 'eip155:1/slip44:60', - 'bip122:000000000019d6689c085ae165831e93/slip44:0', - 'swift:0/iso4217:USD', - 'swift:0/unknown:unknown', - ]); - - expect(mockAssetRates.spotPrices).toHaveBeenCalled(); - expect(result).toStrictEqual([ - ['eip155:1/slip44:60', mockExchangeRatesETH], - [ - 'bip122:000000000019d6689c085ae165831e93/slip44:0', - mockExchangeRatesBTC, - ], - ['swift:0/iso4217:USD', mockExchangeRatesUSD], - ['swift:0/unknown:unknown', null], - ]); - }); - - it('returns null for assets when spotPrices fails', async () => { - const error = new Error('getRates failed'); - mockCache.get.mockResolvedValue(undefined); - mockAssetRates.spotPrices.mockRejectedValue(error); - - const result = await useCases.getRates([Caip19Asset.Testnet]); - - expect(mockSnapClient.emitTrackingError).toHaveBeenCalledTimes(1); - expect(mockSnapClient.emitTrackingError).toHaveBeenCalledWith(error); - expect(mockLogger.warn).toHaveBeenCalledWith( - 'Failed to fetch spot price for ticker btc', - error, - ); - expect(result).toStrictEqual([[Caip19Asset.Testnet, null]]); - }); - - it('uses cached values when available', async () => { - const cachedSpotPrice = mock({ - price: 42000, - marketData: { - allTimeHigh: '110000', - }, - }); - - mockCache.get.mockResolvedValue(cachedSpotPrice); - - const result = await useCases.getRates(['swift:0/iso4217:USD']); - - expect(mockCache.get).toHaveBeenCalledWith('spotPrices:usd'); - expect(mockAssetRates.spotPrices).not.toHaveBeenCalled(); - expect(result).toStrictEqual([['swift:0/iso4217:USD', cachedSpotPrice]]); - }); - - it('caches fetched spot prices with 30 second TTL', async () => { - const mockSpotPrice = mock({ - price: 50000, - marketData: { - allTimeHigh: '110000', - }, - }); - - mockCache.get.mockResolvedValue(undefined); - mockAssetRates.spotPrices.mockResolvedValue(mockSpotPrice); - - await useCases.getRates(['swift:0/iso4217:USD']); - - expect(mockCache.set).toHaveBeenCalledWith( - 'spotPrices:usd', - mockSpotPrice, - 30000, - ); - }); - - it('deduplicates requests for assets with the same ticker', async () => { - const mockSpotPrice = mock({ - price: 50000, - marketData: { - allTimeHigh: '110000', - }, - }); - - // First call to cache.get returns undefined (cache miss) - // Subsequent calls return the cached value (cache hit) - mockCache.get - .mockResolvedValueOnce(undefined) - .mockResolvedValue(mockSpotPrice); - mockAssetRates.spotPrices.mockResolvedValue(mockSpotPrice); - - // Multiple assets that map to the same ticker (usd) - const result = await useCases.getRates([ - 'swift:0/iso4217:USD', - 'swift:1/iso4217:USD', - 'swift:2/iso4217:USD', - ]); - - // Should only call spotPrices once for the unique ticker - expect(mockAssetRates.spotPrices).toHaveBeenCalledTimes(1); - expect(mockAssetRates.spotPrices).toHaveBeenCalledWith('usd'); - - // All assets should get the same spot price - expect(result).toStrictEqual([ - ['swift:0/iso4217:USD', mockSpotPrice], - ['swift:1/iso4217:USD', mockSpotPrice], - ['swift:2/iso4217:USD', mockSpotPrice], - ]); - }); - }); - - describe('getPriceIntervals', () => { - it('returns prices against the specified token', async () => { - const mockHistoricalPrices = mock(); - mockAssetRates.historicalPrices.mockResolvedValue(mockHistoricalPrices); - - const result = await useCases.getPriceIntervals('swift:0/iso4217:USD'); - - expect(mockAssetRates.historicalPrices).toHaveBeenCalledTimes(6); - expect(result).toStrictEqual({ - P1D: mockHistoricalPrices, - P7D: mockHistoricalPrices, - P1M: mockHistoricalPrices, - P3M: mockHistoricalPrices, - P1Y: mockHistoricalPrices, - P1000Y: mockHistoricalPrices, - }); - }); - - it('returns empty arrays for periods when historicalPrices fails', async () => { - const error = new Error('historicalPrices failed'); - mockAssetRates.historicalPrices.mockRejectedValue(error); - - const result = await useCases.getPriceIntervals('swift:0/iso4217:USD'); - - expect(mockSnapClient.emitTrackingError).toHaveBeenCalledTimes(6); - expect(mockSnapClient.emitTrackingError).toHaveBeenCalledWith(error); - expect(mockLogger.warn).toHaveBeenCalledTimes(6); - expect(mockLogger.warn).toHaveBeenCalledWith( - 'Failed to fetch historical prices for period P1D', - error, - ); - expect(result).toStrictEqual({ - P1D: [], - P7D: [], - P1M: [], - P3M: [], - P1Y: [], - P1000Y: [], - }); - }); - }); -}); diff --git a/packages/bitcoin-wallet-snap/src/use-cases/AssetsUseCases.ts b/packages/bitcoin-wallet-snap/src/use-cases/AssetsUseCases.ts deleted file mode 100644 index 0c5e12f98..000000000 --- a/packages/bitcoin-wallet-snap/src/use-cases/AssetsUseCases.ts +++ /dev/null @@ -1,171 +0,0 @@ -import slip44 from '@metamask/slip44'; -import type { HistoricalPriceIntervals } from '@metamask/snaps-sdk'; -import type { CaipAssetType } from '@metamask/utils'; -import { parseCaipAssetType } from '@metamask/utils'; - -import type { - AssetRate, - AssetRatesClient, - Logger, - SnapClient, - SpotPrice, - TimePeriod, -} from '../entities'; -import type { ICache, Serializable } from '../store/ICache'; - -export class AssetsUseCases { - readonly #logger: Logger; - - readonly #assetRates: AssetRatesClient; - - readonly #cache: ICache; - - readonly #snapClient: SnapClient; - - constructor( - logger: Logger, - assetRates: AssetRatesClient, - cache: ICache, - snapClient: SnapClient, - ) { - this.#logger = logger; - this.#assetRates = assetRates; - this.#cache = cache; - this.#snapClient = snapClient; - } - - async getRates(assets: CaipAssetType[]): Promise { - this.#logger.debug('Fetching BTC rates for: %o', assets); - - // group assets by ticker to deduplicate API calls. Multiple CAIP asset types - // can map to the same ticker (e.g., 'bip122:000000000019d6689c085ae165831e93/slip44:0' - // and other BTC representations both resolve to 'btc'), so we fetch each ticker only once. - const tickerToAssets = new Map(); - const assetsWithoutTicker: CaipAssetType[] = []; - - for (const asset of assets) { - const ticker = this.#assetToTicker(asset); - if (!ticker) { - assetsWithoutTicker.push(asset); - continue; - } - - const existing = tickerToAssets.get(ticker); - if (existing) { - existing.push(asset); - } else { - tickerToAssets.set(ticker, [asset]); - } - } - - // fetch all unique tickers in parallel. Each promise handles - // its own errors via .catch() to prevent one failure from breaking all fetches. - const promises = Array.from(tickerToAssets.entries()).map( - async ([ticker, tickerAssets]) => { - const cacheKey = `spotPrices:${ticker}`; - const cachedValue = await this.#cache.get(cacheKey); - - if (cachedValue !== undefined) { - return tickerAssets.map( - (asset) => [asset, cachedValue as SpotPrice] as AssetRate, - ); - } - - return this.#assetRates - .spotPrices(ticker) - .then(async (spotPrices) => { - await this.#cache.set(cacheKey, spotPrices, 30000); - return tickerAssets.map( - (asset) => [asset, spotPrices] as AssetRate, - ); - }) - .catch(async (error) => { - await this.#snapClient.emitTrackingError(error as Error); - - this.#logger.warn( - `Failed to fetch spot price for ticker ${ticker}`, - error, - ); - return tickerAssets.map((asset) => [asset, null] as AssetRate); - }); - }, - ); - - const results = await Promise.all(promises); - const ratesMap = new Map(); - - // flatten results from ticker-grouped arrays back to individual asset rates - results.flat().forEach(([asset, rate]) => { - ratesMap.set(asset, rate); - }); - - assetsWithoutTicker.forEach((asset) => { - ratesMap.set(asset, null); - }); - - this.#logger.debug('BTC rates fetched successfully'); - - return assets.map((asset) => { - const rate = ratesMap.get(asset); - return [asset, rate ?? null]; - }); - } - - async getPriceIntervals( - to: CaipAssetType, - ): Promise { - this.#logger.debug('Fetching BTC historical prices. To %s', to); - - const timePeriods: TimePeriod[] = [ - 'P1D', - 'P7D', - 'P1M', - 'P3M', - 'P1Y', - 'P1000Y', - ]; - const vsCurrency = this.#assetToTicker(to); - - const promises = timePeriods.map(async (timePeriod) => - this.#assetRates - .historicalPrices(timePeriod, vsCurrency) - .then((prices) => ({ timePeriod, prices })) - .catch(async (error) => { - await this.#snapClient.emitTrackingError(error as Error); - - this.#logger.warn( - `Failed to fetch historical prices for period ${timePeriod}`, - error, - ); - return { timePeriod, prices: [] }; - }), - ); - - const results = await Promise.all(promises); - - this.#logger.debug('BTC historical prices fetched successfully'); - return results.reduce( - (acc, { timePeriod, prices }) => { - acc[timePeriod] = prices; - return acc; - }, - {}, - ); - } - - #assetToTicker(asset: CaipAssetType): string | undefined { - const { assetNamespace, assetReference } = parseCaipAssetType(asset); - - if (assetNamespace === 'iso4217') { - return assetReference.toLowerCase(); - } - - if (assetNamespace === 'slip44') { - return slip44[ - assetReference as keyof typeof slip44 - ]?.symbol.toLowerCase(); - } - - return undefined; - } -} diff --git a/packages/bitcoin-wallet-snap/src/use-cases/index.ts b/packages/bitcoin-wallet-snap/src/use-cases/index.ts index 15721d739..7f154bc24 100644 --- a/packages/bitcoin-wallet-snap/src/use-cases/index.ts +++ b/packages/bitcoin-wallet-snap/src/use-cases/index.ts @@ -1,4 +1,3 @@ export * from './AccountUseCases'; export * from './SendFlowUseCases'; -export * from './AssetsUseCases'; export * from './ConfirmationUseCases';