diff --git a/packages/assets-controllers/src/MultichainAssetsRatesController/MultichainAssetsRatesController.test.ts b/packages/assets-controllers/src/MultichainAssetsRatesController/MultichainAssetsRatesController.test.ts index 1da5feef59d..f61e81681a8 100644 --- a/packages/assets-controllers/src/MultichainAssetsRatesController/MultichainAssetsRatesController.test.ts +++ b/packages/assets-controllers/src/MultichainAssetsRatesController/MultichainAssetsRatesController.test.ts @@ -99,7 +99,8 @@ const fakeHistoricalPrices: OnAssetHistoricalPriceResponse = { ], }, updateTime: 1737542312, - expirationTime: 1737542312, + // expirationTime is in 1Hour based on current Date.now() + expirationTime: Date.now() + 1000 * 60 * 60, }, }; @@ -627,5 +628,97 @@ describe('MultichainAssetsRatesController', () => { expect(snapHandler).toHaveBeenCalledTimes(1); }); + + it('does not clean up any of the prices if none of them have expired', async () => { + const testCurrency = 'EUR'; + const testNativeAssetPrices = { + intervals: {}, + updateTime: Date.now(), + expirationTime: Date.now() + 1000, // not expired + }; + const testTokenAssetPrices = { + intervals: {}, + updateTime: Date.now(), + expirationTime: Date.now() + 1000, // not expired + }; + const { controller, messenger } = setupController({ + config: { + state: { + historicalPrices: { + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501': { + [testCurrency]: testNativeAssetPrices, + }, + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:testToken1': { + [testCurrency]: testTokenAssetPrices, + }, + }, + }, + }, + }); + + const snapHandler = jest.fn().mockResolvedValue(fakeHistoricalPrices); + messenger.registerActionHandler( + 'SnapController:handleRequest', + snapHandler, + ); + + await controller.fetchHistoricalPricesForAsset( + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + ); + + expect(snapHandler).toHaveBeenCalledTimes(1); + expect(controller.state.historicalPrices).toStrictEqual({ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501': { + USD: fakeHistoricalPrices.historicalPrice, + EUR: testNativeAssetPrices, + }, + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:testToken1': { + EUR: testTokenAssetPrices, + }, + }); + }); + + it('cleans up all historical prices that have expired', async () => { + const testCurrency = 'EUR'; + const { controller, messenger } = setupController({ + config: { + state: { + historicalPrices: { + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501': { + [testCurrency]: { + intervals: {}, + updateTime: Date.now(), + expirationTime: Date.now() - 1000, // expired + }, + }, + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:testToken1': { + [testCurrency]: { + intervals: {}, + updateTime: Date.now(), + expirationTime: Date.now() - 1000, // expired + }, + }, + }, + }, + }, + }); + + const snapHandler = jest.fn().mockResolvedValue(fakeHistoricalPrices); + messenger.registerActionHandler( + 'SnapController:handleRequest', + snapHandler, + ); + + await controller.fetchHistoricalPricesForAsset( + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + ); + + expect(snapHandler).toHaveBeenCalledTimes(1); + expect(controller.state.historicalPrices).toStrictEqual({ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501': { + USD: fakeHistoricalPrices.historicalPrice, + }, + }); + }); }); }); diff --git a/packages/assets-controllers/src/MultichainAssetsRatesController/MultichainAssetsRatesController.ts b/packages/assets-controllers/src/MultichainAssetsRatesController/MultichainAssetsRatesController.ts index e8e98d13774..d927961c9d0 100644 --- a/packages/assets-controllers/src/MultichainAssetsRatesController/MultichainAssetsRatesController.ts +++ b/packages/assets-controllers/src/MultichainAssetsRatesController/MultichainAssetsRatesController.ts @@ -28,6 +28,7 @@ import type { import { HandlerType } from '@metamask/snaps-utils'; import { Mutex } from 'async-mutex'; import type { Draft } from 'immer'; +import { isEqual } from 'lodash'; import { MAP_CAIP_CURRENCIES } from './constant'; import type { @@ -389,6 +390,8 @@ export class MultichainAssetsRatesController extends StaticIntervalPollingContro }, }; }); + // cleanup all historical prices that have expired + this.#cleanupHistoricalPrices(); } catch { throw new Error( `Failed to fetch historical prices for asset: ${asset}`, @@ -399,6 +402,45 @@ export class MultichainAssetsRatesController extends StaticIntervalPollingContro }); } + #cleanupHistoricalPrices() { + const allHistoricalPrices = this.state.historicalPrices; + const cleanedHistoricalPrices = + this.#removeExpiredEntries(allHistoricalPrices); + + // do not update state if no changes + if (isEqual(allHistoricalPrices, cleanedHistoricalPrices)) { + return; + } + + this.update((state) => { + state.historicalPrices = cleanedHistoricalPrices; + }); + } + + #removeExpiredEntries( + data: Record>, + ): Record> { + const now = Date.now(); + const result: Record> = {}; + + Object.entries(data).forEach(([assetId, currencies]) => { + const validCurrencies: Record = {}; + + Object.entries(currencies).forEach(([currency, details]) => { + const exp = details.expirationTime; + if (exp === undefined || exp > now) { + validCurrencies[currency] = details; + } + }); + + if (Object.keys(validCurrencies).length > 0) { + result[assetId as CaipAssetType] = validCurrencies; + } + }); + + return result; + } + /** * Returns the array of CAIP-19 assets for the given account ID. * If none are found, returns an empty array.