diff --git a/packages/assets-controllers/src/TokenCacheService.ts b/packages/assets-controllers/src/TokenCacheService.ts new file mode 100644 index 00000000000..03baa4c55c5 --- /dev/null +++ b/packages/assets-controllers/src/TokenCacheService.ts @@ -0,0 +1,127 @@ +import type { Hex } from '@metamask/utils'; + +/** + * Token metadata for a single token + */ +export type TokenListToken = { + name: string; + symbol: string; + decimals: number; + address: string; + occurrences: number; + aggregators: string[]; + iconUrl: string; +}; + +/** + * Map of token addresses to token metadata + */ +export type TokenListMap = Record; + +/** + * Cache entry containing token list data and timestamp + */ +type DataCache = { + timestamp: number; + data: TokenListMap; +}; + +/** + * Cache structure mapping chain IDs to token lists + */ +export type TokensChainsCache = { + [chainId: Hex]: DataCache; +}; + +/** + * Service for managing token list cache outside of controller state + * This provides in-memory token metadata storage without persisting to disk + */ +export class TokenCacheService { + readonly #cache: Map = new Map(); + + readonly #cacheThreshold: number; + + /** + * Creates a new TokenCacheService instance + * + * @param cacheThreshold - Time in milliseconds before cache is considered stale (default: 24 hours) + */ + constructor(cacheThreshold: number = 24 * 60 * 60 * 1000) { + this.#cacheThreshold = cacheThreshold; + } + + /** + * Get cache entry for a specific chain + * + * @param chainId - Chain ID in hex format + * @returns Cache entry with token list data and timestamp, or undefined if not cached + */ + get(chainId: Hex): DataCache | undefined { + return this.#cache.get(chainId); + } + + /** + * Set cache entry for a specific chain + * + * @param chainId - Chain ID in hex format + * @param data - Token list data to cache + * @param timestamp - Optional timestamp (defaults to current time) + */ + set(chainId: Hex, data: TokenListMap, timestamp: number = Date.now()): void { + this.#cache.set(chainId, { data, timestamp }); + } + + /** + * Check if cache entry for a chain is still valid (not expired) + * + * @param chainId - Chain ID in hex format + * @returns True if cache exists and is not expired + */ + isValid(chainId: Hex): boolean { + const entry = this.#cache.get(chainId); + if (!entry) { + return false; + } + return Date.now() - entry.timestamp < this.#cacheThreshold; + } + + /** + * Get all cached token lists across all chains + * + * @returns Complete cache structure with all chains + */ + getAll(): TokensChainsCache { + const result: TokensChainsCache = {}; + this.#cache.forEach((value, key) => { + result[key] = value; + }); + return result; + } + + /** + * Clear all cached token lists + */ + clear(): void { + this.#cache.clear(); + } + + /** + * Remove cache entry for a specific chain + * + * @param chainId - Chain ID in hex format + * @returns True if an entry was removed + */ + delete(chainId: Hex): boolean { + return this.#cache.delete(chainId); + } + + /** + * Get number of cached chains + * + * @returns Count of cached chains + */ + get size(): number { + return this.#cache.size; + } +} diff --git a/packages/assets-controllers/src/TokenDetectionController.ts b/packages/assets-controllers/src/TokenDetectionController.ts index 066f7a21a4d..621d3de522b 100644 --- a/packages/assets-controllers/src/TokenDetectionController.ts +++ b/packages/assets-controllers/src/TokenDetectionController.ts @@ -51,8 +51,10 @@ import { } from './multi-chain-accounts-service'; import type { GetTokenListState, + GetTokenListForChain, + GetAllTokenLists, TokenListMap, - TokenListStateChange, + TokenListCacheUpdate, TokensChainsCache, } from './TokenListController'; import type { Token } from './TokenRatesController'; @@ -140,6 +142,8 @@ export type AllowedActions = | NetworkControllerGetNetworkConfigurationByNetworkClientId | NetworkControllerGetStateAction | GetTokenListState + | GetTokenListForChain + | GetAllTokenLists | KeyringControllerGetStateAction | PreferencesControllerGetStateAction | TokensControllerGetStateAction @@ -157,7 +161,7 @@ export type TokenDetectionControllerEvents = export type AllowedEvents = | AccountsControllerSelectedEvmAccountChangeEvent | NetworkControllerNetworkDidChangeEvent - | TokenListStateChange + | TokenListCacheUpdate | KeyringControllerLockEvent | KeyringControllerUnlockEvent | PreferencesControllerStateChangeEvent @@ -337,12 +341,10 @@ export class TokenDetectionController extends StaticIntervalPollingController { + 'TokenListController:cacheUpdate', + async (tokensChainsCache) => { const isEqualValues = this.#compareTokensChainsCache( tokensChainsCache, this.#tokensChainsCache, @@ -666,10 +668,8 @@ export class TokenDetectionController extends StaticIntervalPollingController; -export type TokenListControllerEvents = TokenListStateChange; +export type TokenListCacheUpdate = { + type: `${typeof name}:cacheUpdate`; + payload: [TokensChainsCache]; +}; + +export type TokenListControllerEvents = + | TokenListStateChange + | TokenListCacheUpdate; export type GetTokenListState = ControllerGetStateAction< typeof name, TokenListState >; -export type TokenListControllerActions = GetTokenListState; +export type GetTokenListForChain = { + type: `${typeof name}:getTokenListForChain`; + handler: (chainId: Hex) => TokenListMap | undefined; +}; + +export type GetAllTokenLists = { + type: `${typeof name}:getAllTokenLists`; + handler: () => TokensChainsCache; +}; + +export type TokenListControllerActions = + | GetTokenListState + | GetTokenListForChain + | GetAllTokenLists; type AllowedActions = NetworkControllerGetNetworkClientByIdAction; @@ -76,12 +97,6 @@ export type TokenListControllerMessenger = Messenger< >; const metadata: StateMetadata = { - tokensChainsCache: { - includeInStateLogs: false, - persist: true, - includeInDebugSnapshot: true, - usedInUi: true, - }, preventPollingOnNetworkRestart: { includeInStateLogs: false, persist: true, @@ -92,7 +107,6 @@ const metadata: StateMetadata = { export const getDefaultTokenListState = (): TokenListState => { return { - tokensChainsCache: {}, preventPollingOnNetworkRestart: false, }; }; @@ -122,6 +136,8 @@ export class TokenListController extends StaticIntervalPollingController { + return this.cacheService.get(requestedChainId)?.data; + }, + ); + + this.messenger.registerActionHandler(`${name}:getAllTokenLists`, () => { + return this.cacheService.getAll(); + }); + if (onNetworkStateChange) { // TODO: Either fix this lint violation or explain why it's necessary to ignore. // eslint-disable-next-line @typescript-eslint/no-misused-promises @@ -293,7 +325,7 @@ export class TokenListController extends StaticIntervalPollingController, ); - // Have response - process and update list + // Have response - process and update cache if (tokensFromAPI) { - // Format tokens from API (HTTP) and update tokenList + // Format tokens from API (HTTP) and update cache const tokenList: TokenListMap = {}; for (const token of tokensFromAPI) { tokenList[token.address] = { @@ -328,22 +360,21 @@ export class TokenListController extends StaticIntervalPollingController { - const newDataCache: DataCache = { data: {}, timestamp: Date.now() }; - state.tokensChainsCache[chainId] ??= newDataCache; - state.tokensChainsCache[chainId].data = tokenList; - state.tokensChainsCache[chainId].timestamp = Date.now(); - }); + this.cacheService.set(chainId, tokenList); + this.messenger.publish( + `${name}:cacheUpdate`, + this.cacheService.getAll(), + ); return; } - // No response - fallback to previous state, or initialise empty + // No response - set empty cache with timestamp to prevent repeated failed fetches if (!tokensFromAPI) { - this.update((state) => { - const newDataCache: DataCache = { data: {}, timestamp: Date.now() }; - state.tokensChainsCache[chainId] ??= newDataCache; - state.tokensChainsCache[chainId].timestamp = Date.now(); - }); + this.cacheService.set(chainId, {}); + this.messenger.publish( + `${name}:cacheUpdate`, + this.cacheService.getAll(), + ); } } finally { releaseLock(); @@ -351,24 +382,15 @@ export class TokenListController extends StaticIntervalPollingController { - return { - ...this.state, - tokensChainsCache: {}, - }; - }); + this.cacheService.clear(); + this.messenger.publish(`${name}:cacheUpdate`, this.cacheService.getAll()); } /** diff --git a/packages/assets-controllers/src/TokensController.ts b/packages/assets-controllers/src/TokensController.ts index cafd25dc26b..7a7b995575c 100644 --- a/packages/assets-controllers/src/TokensController.ts +++ b/packages/assets-controllers/src/TokensController.ts @@ -52,8 +52,9 @@ import { TOKEN_METADATA_NO_SUPPORT_ERROR, } from './token-service'; import type { - TokenListStateChange, + TokenListCacheUpdate, TokenListToken, + TokensChainsCache, } from './TokenListController'; import type { Token } from './TokenRatesController'; @@ -154,7 +155,7 @@ export type TokensControllerEvents = TokensControllerStateChangeEvent; export type AllowedEvents = | NetworkControllerStateChangeEvent | NetworkControllerNetworkDidChangeEvent - | TokenListStateChange + | TokenListCacheUpdate | AccountsControllerSelectedEvmAccountChangeEvent | KeyringControllerAccountRemovedEvent; @@ -252,8 +253,8 @@ export class TokensController extends BaseController< ); this.messenger.subscribe( - 'TokenListController:stateChange', - ({ tokensChainsCache }) => { + 'TokenListController:cacheUpdate', + (tokensChainsCache: TokensChainsCache) => { const { allTokens } = this.state; const selectedAddress = this.#getSelectedAddress(); diff --git a/packages/assets-controllers/src/index.ts b/packages/assets-controllers/src/index.ts index f119072a564..4360cb53664 100644 --- a/packages/assets-controllers/src/index.ts +++ b/packages/assets-controllers/src/index.ts @@ -97,12 +97,17 @@ export type { TokenListToken, TokenListMap, TokenListStateChange, + TokenListCacheUpdate, TokenListControllerEvents, GetTokenListState, + GetTokenListForChain, + GetAllTokenLists, TokenListControllerActions, TokenListControllerMessenger, + TokensChainsCache, } from './TokenListController'; export { TokenListController } from './TokenListController'; +export { TokenCacheService } from './TokenCacheService'; export type { ContractExchangeRates, ContractMarketData,