diff --git a/packages/assets-controllers/CHANGELOG.md b/packages/assets-controllers/CHANGELOG.md index ccb00b0d13..b659b6527d 100644 --- a/packages/assets-controllers/CHANGELOG.md +++ b/packages/assets-controllers/CHANGELOG.md @@ -14,8 +14,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `CurrencyRateController:setCurrentCurrency` - `CurrencyRateController:updateExchangeRate` - Corresponding action types (e.g. `CurrencyRateControllerSetCurrentCurrencyAction`) are available as well. - -### Changed +- `TokensController` and `MultichainAssetsController` now fetch and store token security data for trust badges + - Security data includes `resultType` (e.g., Verified, Malicious, Spam) and `lastFetchedAt` timestamp + - Implements smart caching: security data is refreshed only if older than 12 hours + - Fail-open strategy: security data fetching never blocks token addition or balance updates + +### Changed + +- **BREAKING:** `TokensController` constructor now requires a `useExternalServices` callback parameter + - This callback controls whether external API calls are allowed (e.g., for privacy/basic functionality toggle) + - Clients must pass a function that returns the current state of `PreferencesController.state.useExternalServices` + - Example: `useExternalServices: () => preferencesController.state.useExternalServices ?? true` + - The callback is invoked at runtime each time security data is fetched, respecting user privacy settings changes +- **BREAKING:** `TokensController` now requires clients to call `init()` method after instantiation + - The `init()` method triggers an initial security scan for the current account's tokens + - Must be called after all controllers are instantiated to avoid initialization order dependencies + - Example: `await tokensController.init()` +- **BREAKING:** `MultichainAssetsController` constructor now requires a `useExternalServices` callback parameter + - This callback controls whether external API calls (Blockaid scans) are allowed + - Clients must pass a function that returns the current state of `PreferencesController.state.useExternalServices` + - Example: `useExternalServices: () => preferencesController.state.useExternalServices ?? true` + - The callback is invoked at runtime each time security scans are performed +- **BREAKING:** `MultichainAssetsController` now requires clients to call `init()` method after instantiation + - The `init()` method triggers an immediate security scan for all accounts' non-EVM assets + - Must be called after all controllers are instantiated to avoid initialization order dependencies + - Example: `await multichainAssetsController.init()` +- `TokenSecurityInfo` type now includes `lastFetchedAt: number` field for smart caching (12-hour freshness window) +- `TokensController` now automatically scans tokens for security data when user switches accounts + - Security scans are cache-aware and only fetch data for tokens with missing or stale security info (>12 hours old) + - Scanning is fire-and-forget and never blocks the UI - **BREAKING:** Standardize names of `CurrencyRateController` messenger action types ([#8561](https://github.com/MetaMask/core/pull/8561)) - The `GetCurrencyRateState` messenger action has been renamed to `CurrencyRateControllerGetStateAction` to follow the convention. You will need to update imports appropriately. diff --git a/packages/assets-controllers/src/AccountTrackerController.ts b/packages/assets-controllers/src/AccountTrackerController.ts index 6252544047..f823daf3d3 100644 --- a/packages/assets-controllers/src/AccountTrackerController.ts +++ b/packages/assets-controllers/src/AccountTrackerController.ts @@ -13,6 +13,7 @@ import { query, safelyExecuteWithTimeout, toChecksumHexAddress, + convertHexToDecimal, } from '@metamask/controller-utils'; import EthQuery from '@metamask/eth-query'; import type { @@ -33,6 +34,7 @@ import type { NetworkEnablementControllerGetStateAction, NetworkEnablementControllerListPopularEvmNetworksAction, } from '@metamask/network-enablement-controller'; +import { Slip44Service } from '@metamask/network-enablement-controller'; import { StaticIntervalPollingController } from '@metamask/polling-controller'; import type { TransactionControllerTransactionConfirmedEvent, @@ -58,6 +60,13 @@ import type { ProcessedBalance, } from './multi-chain-accounts-service/api-balance-fetcher'; import { RpcBalanceFetcher } from './rpc-service/rpc-balance-fetcher'; +import { shouldFetchSecurityData } from './assetsUtil'; +import type { CaipAssetType } from '@metamask/utils'; +import { + fetchSecurityDataForAssets, + type TokenSecurityInfo, +} from './token-service'; +import { SPOT_PRICES_SUPPORT_INFO } from './token-prices-service/codefi-v2'; /** * The name of the {@link AccountTrackerController}. @@ -125,6 +134,7 @@ function createAccountTrackerRpcBalanceFetcher( }; } + /** * AccountInformation * @@ -133,10 +143,13 @@ function createAccountTrackerRpcBalanceFetcher( * balance - Hex string of an account balance in wei * * stakedBalance - Hex string of an account staked balance in wei + * + * nativeSecurity - Security information for the native token on this chain */ export type AccountInformation = { balance: string; stakedBalance?: string; + nativeSecurity?: TokenSecurityInfo; }; /** @@ -260,6 +273,8 @@ export class AccountTrackerController extends StaticIntervalPollingController boolean; + readonly #allowExternalServices: () => boolean; + /** Track if the keyring is locked */ #isLocked = true; @@ -341,6 +356,7 @@ export class AccountTrackerController extends StaticIntervalPollingController { + // Step 1: Check if we have it in the price API constant + const knownAssetId = + SPOT_PRICES_SUPPORT_INFO[ + chainId as keyof typeof SPOT_PRICES_SUPPORT_INFO + ]; + if (knownAssetId) { + return knownAssetId; + } + + // Step 2: Compute dynamically using Slip44Service + const decimalChainId = convertHexToDecimal(chainId); + const slip44CoinType = await Slip44Service.getEvmSlip44( + Number(decimalChainId), + ); + + return `eip155:${decimalChainId}/slip44:${slip44CoinType}`; + } + + /** + * Fetches security data for native tokens on given chains. + * Uses /assets API with CAIP-19 slip44 format. + * Respects cache - only fetches for chains with stale data. + * Handles batching automatically (max 100 chains per request). + * + * @param chainIds - Array of chain IDs to fetch security for + * @returns Map of chainId -> TokenSecurityInfo + */ + async #fetchNativeTokenSecurityBatch( + chainIds: Hex[], + ): Promise> { + if (!this.#allowExternalServices()) { + return {}; + } + + if (chainIds.length === 0) { + return {}; + } + + // Filter chains that need security data refresh (cache-aware) + const chainsNeedingFetch = chainIds.filter((chainId) => { + // Check if any account on this chain has stale/missing security data + const chainAccounts = this.state.accountsByChainId[chainId]; + if (!chainAccounts) { + return true; + } + + const firstAccount = Object.values(chainAccounts)[0]; + return shouldFetchSecurityData(firstAccount?.nativeSecurity?.lastFetchedAt); + }); + + if (chainsNeedingFetch.length === 0) { + return {}; // All chains have fresh data + } + + // Resolve CAIP-19 IDs for native tokens (slip44 format) + const caipIds = await Promise.all( + chainsNeedingFetch.map(async (chainId) => ({ + chainId, + caipId: await this.#getNativeAssetId(chainId), + })), + ); + + // Fetch security data (handles batching internally) + const securityDataByAssetId = await fetchSecurityDataForAssets( + caipIds.map(({ caipId }) => caipId) as CaipAssetType[], + ); + + // Map back from assetId -> chainId + const securityMap: Record = {}; + for (const { chainId, caipId } of caipIds) { + if (securityDataByAssetId[caipId]) { + securityMap[chainId] = securityDataByAssetId[caipId]; + } + } + + return securityMap; + } + /** * Resolves a networkClientId to a network client config * or globally selected network config if not provided @@ -855,6 +957,36 @@ export class AccountTrackerController extends StaticIntervalPollingController { diff --git a/packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController.ts b/packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController.ts index 3bd5ee7e12..4e6b79e806 100644 --- a/packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController.ts +++ b/packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController.ts @@ -43,12 +43,31 @@ import { Mutex } from 'async-mutex'; import type { MultichainAssetsControllerMethodActions } from './MultichainAssetsController-method-action-types'; import { getChainIdsCaveat } from './utils'; +import { shouldFetchSecurityData } from '../assetsUtil'; const controllerName = 'MultichainAssetsController'; +/** + * Minimal token security information for displaying trust badges. + * Stores only the result type from Blockaid scans. + * Includes lastFetchedAt timestamp for smart caching (12-hour freshness). + */ +type TokenSecurityInfo = { + resultType: string; + lastFetchedAt: number; +}; + +/** + * Extends FungibleAssetMetadata with security information. + * Security is optional to support gradual enrichment (existing assets may not have it yet). + */ +type AssetMetadataWithSecurity = FungibleAssetMetadata & { + security?: TokenSecurityInfo; +}; + export type MultichainAssetsControllerState = { assetsMetadata: { - [asset: CaipAssetType]: FungibleAssetMetadata; + [asset: CaipAssetType]: AssetMetadataWithSecurity; }; accountsAssets: { [account: string]: CaipAssetType[] }; allIgnoredAssets: { [account: string]: CaipAssetType[] }; @@ -212,15 +231,20 @@ export class MultichainAssetsController extends StaticIntervalPollingController< readonly #controllerOperationMutex = new Mutex(); + readonly #useExternalServices: () => boolean; + constructor({ messenger, state = {}, blockaidTokenRescanInterval = DEFAULT_BLOCKAID_TOKEN_RESCAN_INTERVAL_MS, + useExternalServices = (): boolean => true, }: { messenger: MultichainAssetsControllerMessenger; state?: Partial; /** Blockaid re-scan interval (ms); default daily. `0` disables. */ blockaidTokenRescanInterval?: number; + /** Callback that returns whether external API calls are allowed (privacy/basic functionality toggle). */ + useExternalServices?: () => boolean; }) { super({ messenger, @@ -233,6 +257,7 @@ export class MultichainAssetsController extends StaticIntervalPollingController< }); this.#snaps = {}; + this.#useExternalServices = useExternalServices; if (blockaidTokenRescanInterval > 0) { this.setIntervalLength(blockaidTokenRescanInterval); @@ -258,6 +283,16 @@ export class MultichainAssetsController extends StaticIntervalPollingController< messenger.registerMethodActionHandlers(this, MESSENGER_EXPOSED_METHODS); } + /** + * Initialize the controller by scanning all accounts' assets for security data. + * Should be called by clients after all controllers are instantiated. + * Triggers an immediate security scan for all non-EVM (SPL) tokens. + */ + async init(): Promise { + // Trigger an immediate poll to scan all accounts' assets + await this._executePoll(null); + } + async _executePoll(_input: null): Promise { await this.#withControllerLock(async () => { const assetsByAccount: Record< @@ -453,6 +488,8 @@ export class MultichainAssetsController extends StaticIntervalPollingController< const assetsForMetadataRefresh = new Set([]); const accountsAndAssetsToUpdate: AccountAssetListUpdatedEventPayload['assets'] = {}; + const allSecurityData: Record = {}; + for (const [accountId, { added, removed }] of Object.entries( event.assets, )) { @@ -469,9 +506,13 @@ export class MultichainAssetsController extends StaticIntervalPollingController< ); // Filter out tokens that cannot be verified or are flagged malicious - const filteredToBeAddedAssets = + // This also returns security data from the Blockaid scan + const { filteredAssets: filteredToBeAddedAssets, securityData } = await this.#filterBlockaidSpamTokensOnAdd(preFilteredToBeAddedAssets); + // Collect security data + Object.assign(allSecurityData, securityData); + // In case accountsAndAssetsToUpdate event is fired with "removed" assets that don't exist, we don't want to remove them const filteredToBeRemovedAssets = removed.filter( (asset) => existing.includes(asset) && isCaipAssetType(asset), @@ -518,6 +559,20 @@ export class MultichainAssetsController extends StaticIntervalPollingController< // Trigger fetching metadata for new assets await this.#refreshAssetsMetadata(Array.from(assetsForMetadataRefresh)); + // Add security data from Blockaid scans to metadata + if (Object.keys(allSecurityData).length > 0) { + this.update((state) => { + for (const [assetId, securityInfo] of Object.entries( + allSecurityData, + )) { + if (state.assetsMetadata[assetId as CaipAssetType]) { + state.assetsMetadata[assetId as CaipAssetType].security = + securityInfo; + } + } + }); + } + this.messenger.publish(`${controllerName}:accountAssetListUpdated`, { assets: accountsAndAssetsToUpdate, }); @@ -556,13 +611,25 @@ export class MultichainAssetsController extends StaticIntervalPollingController< account.metadata.snap.id, ); const caipAssets = allAssets.filter(isCaipAssetType); - const filteredCaip = + const { filteredAssets: filteredCaip, securityData } = await this.#filterBlockaidSpamTokensOnAdd(caipAssets); const filteredCaipSet = new Set(filteredCaip); const assets = allAssets.filter( (asset) => !isCaipAssetType(asset) || filteredCaipSet.has(asset), ); await this.#refreshAssetsMetadata(assets); + + // Add security data to metadata + if (Object.keys(securityData).length > 0) { + this.update((state) => { + for (const [assetId, securityInfo] of Object.entries(securityData)) { + if (state.assetsMetadata[assetId as CaipAssetType]) { + state.assetsMetadata[assetId as CaipAssetType].security = + securityInfo; + } + } + }); + } this.update((state) => { state.accountsAssets[account.id] = assets; }); @@ -827,22 +894,70 @@ export class MultichainAssetsController extends StaticIntervalPollingController< /** * Fail-open Blockaid filter for newly detected `token:` assets (native/other namespaces unchanged). + * Also returns security data from the scan for badge display. + * Implements smart caching: skips tokens with fresh data (<12 hours old). + * Respects basic functionality toggle: skips scanning if external services disabled. * * @param assets - CAIP assets to filter. - * @returns Filtered list, original order preserved. + * @returns Object with filtered assets and security data from scan. */ async #filterBlockaidSpamTokensOnAdd( assets: CaipAssetType[], - ): Promise { + ): Promise<{ + filteredAssets: CaipAssetType[]; + securityData: Record; + }> { + // Respect basic functionality toggle + if (!this.#useExternalServices()) { + return { filteredAssets: [...assets], securityData: {} }; + } + const tokensByChain = this.#groupTokenAssetsByChain(assets); if (Object.keys(tokensByChain).length === 0) { - return [...assets]; + return { filteredAssets: [...assets], securityData: {} }; } const rejectedAssets = new Set(); + const securityData: Record = {}; + const now = Date.now(); - for (const [chainName, tokenEntries] of Object.entries(tokensByChain)) { + // Filter out assets that already have fresh security data (smart caching) + const assetsToScan: CaipAssetType[] = []; + const tokensByChainFiltered: Record = {}; + + for (const asset of assets) { + const existingMetadata = this.state.assetsMetadata[asset]; + if ( + existingMetadata?.security?.lastFetchedAt && + !shouldFetchSecurityData(existingMetadata.security.lastFetchedAt) + ) { + // Asset has fresh security data, reuse it + securityData[asset] = existingMetadata.security; + } else { + // Asset needs scanning + assetsToScan.push(asset); + } + } + + // Group only assets that need scanning + for (const [chainName, entries] of Object.entries(tokensByChain)) { + const filteredEntries = entries.filter((entry) => + assetsToScan.includes(entry.asset), + ); + if (filteredEntries.length > 0) { + tokensByChainFiltered[chainName] = filteredEntries; + } + } + + // If all assets have fresh data, return early + if (Object.keys(tokensByChainFiltered).length === 0) { + return { filteredAssets: [...assets], securityData }; + } + + for (const [chainName, tokenEntries] of Object.entries( + tokensByChainFiltered, + )) { const batchOutcomes = await this.#runBatchedBulkTokenScans( chainName, tokenEntries, @@ -853,20 +968,29 @@ export class MultichainAssetsController extends StaticIntervalPollingController< // Fail-open: if API fails, allow all tokens in this batch through continue; } + for (const entry of outcome.entries) { const scanned = outcome.response[entry.address]; - // Reject only if we have a definitive malicious result - if ( - scanned?.result_type && - scanned.result_type === TokenScanResultType.Malicious - ) { - rejectedAssets.add(entry.asset); + + if (scanned?.result_type) { + // Store security data with timestamp for all scanned tokens + securityData[entry.asset] = { + resultType: scanned.result_type, + lastFetchedAt: now, + }; + + // Reject only if malicious + if (scanned.result_type === TokenScanResultType.Malicious) { + rejectedAssets.add(entry.asset); + } } } } } - return assets.filter((asset) => !rejectedAssets.has(asset)); + const filteredAssets = assets.filter((asset) => !rejectedAssets.has(asset)); + + return { filteredAssets, securityData }; } /** diff --git a/packages/assets-controllers/src/TokenRatesController.ts b/packages/assets-controllers/src/TokenRatesController.ts index 6b8df1e3f1..6119761eda 100644 --- a/packages/assets-controllers/src/TokenRatesController.ts +++ b/packages/assets-controllers/src/TokenRatesController.ts @@ -17,13 +17,14 @@ import { isEqual } from 'lodash'; import { reduceInBatchesSerially, TOKEN_PRICES_BATCH_SIZE } from './assetsUtil'; import type { AbstractTokenPricesService } from './token-prices-service/abstract-token-prices-service'; import { getNativeTokenAddress } from './token-prices-service/codefi-v2'; -import { TokenRwaData } from './token-service'; +import { TokenRwaData, type TokenSecurityInfo } from './token-service'; import type { TokensControllerGetStateAction, TokensControllerStateChangeEvent, TokensControllerState, } from './TokensController'; + /** * @type Token * @@ -48,6 +49,7 @@ export type Token = { isERC721?: boolean; name?: string; rwaData?: TokenRwaData; + security?: TokenSecurityInfo; }; const DEFAULT_INTERVAL = 180000; diff --git a/packages/assets-controllers/src/TokensController.ts b/packages/assets-controllers/src/TokensController.ts index 5b2ae1e351..cd835e3872 100644 --- a/packages/assets-controllers/src/TokensController.ts +++ b/packages/assets-controllers/src/TokensController.ts @@ -32,6 +32,7 @@ import { abiERC721 } from '@metamask/metamask-eth-abis'; import type { NetworkClientId, NetworkControllerGetNetworkClientByIdAction, + NetworkControllerGetStateAction, NetworkControllerNetworkDidChangeEvent, NetworkControllerStateChangeEvent, NetworkState, @@ -45,20 +46,29 @@ import type { Patch } from 'immer'; import { cloneDeep } from 'lodash'; import { v1 as random } from 'uuid'; -import { formatAggregatorNames, formatIconUrlWithProxy } from './assetsUtil'; +import { + formatAggregatorNames, + formatIconUrlWithProxy, + shouldFetchSecurityData, +} from './assetsUtil'; import { ERC20Standard } from './Standards/ERC20Standard'; import { ERC1155Standard } from './Standards/NftStandards/ERC1155/ERC1155Standard'; import { fetchTokenMetadata, TOKEN_METADATA_NO_SUPPORT_ERROR, TokenRwaData, + fetchSecurityDataForAssets, } from './token-service'; import type { TokenListStateChange, TokenListToken, } from './TokenListController'; import type { Token } from './TokenRatesController'; +import type { TokenSecurityInfo } from './token-service'; import type { TokensControllerMethodActions } from './TokensController-method-action-types'; +import { convertHexToDecimal } from '@metamask/controller-utils'; +import type { CaipAssetType } from '@metamask/utils'; +import { parseCaipAssetType } from '@metamask/utils'; /** * @type SuggestedAssetMeta @@ -132,6 +142,7 @@ const metadata: StateMetadata = { const controllerName = 'TokensController'; + export type TokensControllerGetStateAction = ControllerGetStateAction< typeof controllerName, TokensControllerState @@ -147,6 +158,7 @@ export type TokensControllerActions = export type AllowedActions = | ApprovalControllerAddRequestAction | NetworkControllerGetNetworkClientByIdAction + | NetworkControllerGetStateAction | AccountsControllerGetAccountAction | AccountsControllerGetSelectedAccountAction | AccountsControllerListAccountsAction; @@ -209,6 +221,8 @@ export class TokensController extends BaseController< readonly #abortController: AbortController; + readonly #useExternalServices: () => boolean; + /** * Tokens controller options * @@ -217,16 +231,19 @@ export class TokensController extends BaseController< * @param options.provider - Network provider. * @param options.state - Initial state to set on this controller. * @param options.messenger - The messenger. + * @param options.useExternalServices - Callback that returns whether external API calls are allowed (privacy/basic functionality toggle). */ constructor({ provider, state, messenger, + useExternalServices = (): boolean => true, }: { chainId: Hex; provider: Provider; state?: Partial; messenger: TokensControllerMessenger; + useExternalServices?: () => boolean; }) { super({ name: controllerName, @@ -239,6 +256,7 @@ export class TokensController extends BaseController< }); this.#provider = provider; + this.#useExternalServices = useExternalServices; this.#selectedAccountId = this.#getSelectedAccount().id; @@ -301,6 +319,75 @@ export class TokensController extends BaseController< ); } + /** + * Initialize the controller by scanning current account's tokens for security data. + * Should be called by clients after all controllers are instantiated. + */ + async init(): Promise { + await this.#scanCurrentAccountTokensSecurity(); + } + + /** + * Scans all tokens across all chains for the current selected account for security data. + * Only fetches for tokens that have no security data or stale data (>12 hours). + * Called on app startup (via init()) and when user switches accounts. + */ + async #scanCurrentAccountTokensSecurity(): Promise { + // Respect basic functionality toggle + if (!this.#useExternalServices()) { + return; + } + + try { + const selectedAddress = this.#getSelectedAddress(); + const { allTokens } = this.state; + + // Scan tokens across all chains for this account + for (const [chainId, tokensByAccount] of Object.entries(allTokens)) { + const accountTokens = tokensByAccount[selectedAddress]; + if (!accountTokens || accountTokens.length === 0) { + continue; // No tokens on this chain for this account + } + + // Filter tokens that need scanning (no security data OR stale) + const tokensNeedingScan = accountTokens.filter( + (token) => + !token.security || + shouldFetchSecurityData(token.security.lastFetchedAt), + ); + + if (tokensNeedingScan.length === 0) { + continue; // All tokens on this chain have fresh security data + } + + // Fetch security data (batching handled internally) + const addresses = tokensNeedingScan.map((token) => token.address); + const securityDataMap = await this.#fetchSecurityDataBatch( + addresses, + chainId as Hex, + ); + + // Update state with new security data for this chain + this.update((state) => { + const tokens = state.allTokens[chainId as Hex]?.[selectedAddress]; + if (!tokens) { + return; + } + + tokens.forEach((token) => { + const checksumAddress = toChecksumHexAddress(token.address); + if (securityDataMap[checksumAddress]) { + token.security = securityDataMap[checksumAddress]; + } + }); + }); + } + } catch (error) { + // Fail-open: log but don't block + console.warn('Failed to scan tokens for security data:', error); + } + } + #handleOnAccountRemoved(accountAddress: string) { const isEthAddress = isStrictHexString(accountAddress.toLowerCase()) && @@ -366,11 +453,60 @@ export class TokensController extends BaseController< /** * Handles the selected account change in the accounts controller. + * Updates the selected account ID and scans tokens for security data. * * @param selectedAccount - The new selected account */ #onSelectedAccountChange(selectedAccount: InternalAccount) { this.#selectedAccountId = selectedAccount.id; + // Scan tokens for the new account (fire-and-forget) + this.#scanCurrentAccountTokensSecurity().catch((error) => { + console.warn('Failed to scan tokens on account change:', error); + }); + } + + /** + * Fetch security data for multiple tokens in batch. + * Extracts only the resultType and lastFetchedAt to keep state minimal. + * Respects basic functionality toggle: returns empty map if external services disabled. + * Fail-open: returns empty map on error, never blocks token addition. + * + * @param tokenAddresses - Array of token addresses to fetch security data for. + * @param chainId - Chain ID for the tokens. + * @returns Promise resolving to a map of address to security info. + */ + async #fetchSecurityDataBatch( + tokenAddresses: string[], + chainId: Hex, + ): Promise> { + if (tokenAddresses.length === 0) { + return {}; + } + + // Respect basic functionality toggle + if (!this.#useExternalServices()) { + return {}; + } + + // Build CAIP-19 asset IDs for ERC20 tokens + const chainIdDecimal = convertHexToDecimal(chainId); + const assetIds: CaipAssetType[] = tokenAddresses.map( + (address) => + `eip155:${chainIdDecimal}/erc20:${address.toLowerCase()}` as CaipAssetType, + ); + + // Fetch security data (handles batching internally) + const securityDataByAssetId = await fetchSecurityDataForAssets(assetIds); + + // Map back from assetId -> token address + const securityMap: Record = {}; + for (const [assetId, security] of Object.entries(securityDataByAssetId)) { + const { assetReference } = parseCaipAssetType(assetId as CaipAssetType); + const checksummedAddress = toChecksumHexAddress(assetReference); + securityMap[checksummedAddress] = security; + } + + return securityMap; } /** @@ -475,6 +611,16 @@ export class TokensController extends BaseController< name, ...(rwaData !== undefined && { rwaData }), }; + + // Fetch security data for the token + const securityDataMap = await this.#fetchSecurityDataBatch( + [address], + chainIdToUse, + ); + if (securityDataMap[address]) { + newEntry.security = securityDataMap[address]; + } + const previousIndex = newTokens.findIndex( (token) => token.address.toLowerCase() === address.toLowerCase(), ); @@ -541,6 +687,16 @@ export class TokensController extends BaseController< return output; }, {}); try { + // Fetch security data for tokens being imported (only scan new/updated tokens) + // Batching is handled automatically inside #fetchSecurityDataBatch + const importAddresses = tokensToImport.map((token) => + toChecksumHexAddress(token.address), + ); + const securityDataMap = await this.#fetchSecurityDataBatch( + importAddresses, + interactingChainId, + ); + tokensToImport.forEach((tokenToAdd) => { const { address, symbol, decimals, image, aggregators, name, rwaData } = tokenToAdd; @@ -554,6 +710,12 @@ export class TokensController extends BaseController< name, ...(rwaData && { rwaData }), }; + + // Attach security data to the token being imported + if (securityDataMap[checksumAddress]) { + formattedToken.security = securityDataMap[checksumAddress]; + } + newTokensMap[checksumAddress] = formattedToken; importedTokensMap[address.toLowerCase()] = true; return formattedToken; diff --git a/packages/assets-controllers/src/assetsUtil.ts b/packages/assets-controllers/src/assetsUtil.ts index cf3e77cc6c..5f4cbb1319 100644 --- a/packages/assets-controllers/src/assetsUtil.ts +++ b/packages/assets-controllers/src/assetsUtil.ts @@ -25,6 +25,31 @@ import type { ContractExchangeRates } from './TokenRatesController'; */ export const TOKEN_PRICES_BATCH_SIZE = 30; +/** + * Duration in milliseconds after which security data is considered stale. + * 12 hours = 12 * 60 * 60 * 1000 = 43,200,000 ms + */ +export const SECURITY_DATA_FRESHNESS_THRESHOLD = 12 * 60 * 60 * 1000; + +/** + * Determines if security data needs to be re-fetched. + * + * @param lastFetchedAt - Timestamp (ms) when data was last fetched, or undefined if never fetched. + * @returns True if data is stale or missing (should fetch), false if fresh (skip fetch). + */ +export function shouldFetchSecurityData( + lastFetchedAt: number | undefined, +): boolean { + if (!lastFetchedAt) { + return true; // Never fetched, need to fetch + } + + const now = Date.now(); + const age = now - lastFetchedAt; + + return age > SECURITY_DATA_FRESHNESS_THRESHOLD; // Stale if > 12 hours +} + /** * Compares nft metadata entries to any nft entry. * We need this method when comparing a new fetched nft metadata, in case a entry changed to a defined value, diff --git a/packages/assets-controllers/src/token-service.ts b/packages/assets-controllers/src/token-service.ts index 02464f1556..ccec803854 100644 --- a/packages/assets-controllers/src/token-service.ts +++ b/packages/assets-controllers/src/token-service.ts @@ -553,6 +553,67 @@ export async function fetchTokenAssets( } } +/** + * Minimal token security information type for displaying trust badges. + * Includes timestamp for smart caching (12-hour freshness). + */ +export type TokenSecurityInfo = Pick & { + lastFetchedAt: number; +}; + +/** + * Fetches security data for assets by CAIP-19 ID. + * Handles batching automatically (max 100 assets per API call) using serial processing. + * Returns a map of assetId -> TokenSecurityInfo with timestamps. + * Fails open on errors (returns partial results, continues with remaining batches). + * + * @param assetIds - Array of CAIP-19 asset IDs (e.g., 'eip155:1/erc20:0x...', 'eip155:1/slip44:60') + * @returns Map of assetId to security info with resultType and lastFetchedAt + */ +export async function fetchSecurityDataForAssets( + assetIds: CaipAssetType[], +): Promise> { + if (assetIds.length === 0) { + return {}; + } + + // Import reduceInBatchesSerially dynamically to avoid circular dependency + const { reduceInBatchesSerially } = await import('./assetsUtil.js'); + + return await reduceInBatchesSerially({ + values: assetIds, + batchSize: 100, + eachBatch: async (workingResult, batch) => { + try { + const assets = await fetchTokenAssets(batch, { + includeTokenSecurityData: true, + }); + + const now = Date.now(); + const batchResults: Record = {}; + + for (const asset of assets) { + if (asset.securityData?.resultType) { + batchResults[asset.assetId] = { + resultType: asset.securityData.resultType, + lastFetchedAt: now, + }; + } + } + + return { ...workingResult, ...batchResults } as Record< + string, + TokenSecurityInfo + >; + } catch (error) { + console.warn('Failed to fetch security data for batch:', error); + return workingResult as Record; // Fail-open: skip this batch, keep previous results + } + }, + initialResult: {} as Record, + }); +} + /** * Fetch metadata for the token address provided for a given network. This request is cancellable * using the abort signal passed in.