diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index 92193d82c8f..f4a2a3b62c9 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -14,6 +14,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - **BREAKING:** First-init-fetch measurement moved from MetaMetrics to Sentry. Option `trackMetaMetricsEvent` is replaced by `trace: TraceCallback`. Type `AssetsControllerFirstInitFetchMetaMetricsPayload` is removed; trace request data is not typed in this package. ([#8147](https://github.com/MetaMask/core/pull/8147)) +- `TokenDataSource` now always includes native token asset IDs (from `NetworkEnablementController.nativeAssetIdentifiers`) in metadata fetch calls, ensuring native tokens always have up-to-date metadata ([#8227](https://github.com/MetaMask/core/pull/8227)) +- `TokenDataSource` now filters out non-native tokens with fewer than 3 occurrences from metadata responses, and also removes their balances and detected asset entries, to prevent spam tokens from being stored in state ([#8227](https://github.com/MetaMask/core/pull/8227)) +- `TokenDataSource` now requests `includeOccurrences` when fetching v3 asset metadata ([#8227](https://github.com/MetaMask/core/pull/8227)) ## [2.4.0] diff --git a/packages/assets-controller/src/AssetsController.ts b/packages/assets-controller/src/AssetsController.ts index fb26bb3fccf..7a8164c8a22 100644 --- a/packages/assets-controller/src/AssetsController.ts +++ b/packages/assets-controller/src/AssetsController.ts @@ -651,6 +651,12 @@ export class AssetsController extends BaseController< }); this.#tokenDataSource = new TokenDataSource({ queryApiClient, + getNativeAssetIds: (): string[] => { + const { nativeAssetIdentifiers } = this.messenger.call( + 'NetworkEnablementController:getState', + ); + return Object.values(nativeAssetIdentifiers); + }, }); this.#priceDataSource = new PriceDataSource({ queryApiClient, diff --git a/packages/assets-controller/src/data-sources/TokenDataSource.test.ts b/packages/assets-controller/src/data-sources/TokenDataSource.test.ts index 71940de0cd6..be1bb8fe3b2 100644 --- a/packages/assets-controller/src/data-sources/TokenDataSource.test.ts +++ b/packages/assets-controller/src/data-sources/TokenDataSource.test.ts @@ -107,9 +107,14 @@ function setupController( options: { supportedNetworks?: string[]; assetsResponse?: V3AssetResponse[]; + nativeAssetIds?: string[]; } = {}, ): SetupResult { - const { supportedNetworks = ['eip155:1'], assetsResponse = [] } = options; + const { + supportedNetworks = ['eip155:1'], + assetsResponse = [], + nativeAssetIds = [], + } = options; const rootMessenger = new Messenger({ namespace: MOCK_ANY_NAMESPACE, @@ -120,6 +125,7 @@ function setupController( const controller = new TokenDataSource({ queryApiClient: apiClient as unknown as TokenDataSourceOptions['queryApiClient'], + getNativeAssetIds: (): string[] => nativeAssetIds, }); return { @@ -199,6 +205,7 @@ describe('TokenDataSource', () => { includeMetadata: true, includeRwaData: true, includeAggregators: true, + includeOccurrences: true, }, ); expect(context.response.assetsInfo?.[MOCK_TOKEN_ASSET]).toStrictEqual({ @@ -315,6 +322,7 @@ describe('TokenDataSource', () => { includeMetadata: true, includeRwaData: true, includeAggregators: true, + includeOccurrences: true, }, ); }); @@ -348,6 +356,7 @@ describe('TokenDataSource', () => { includeMetadata: true, includeRwaData: true, includeAggregators: true, + includeOccurrences: true, }, ); }); @@ -542,6 +551,7 @@ describe('TokenDataSource', () => { includeMetadata: true, includeRwaData: true, includeAggregators: true, + includeOccurrences: true, }, ); expect(context.response.assetsInfo?.[MOCK_TOKEN_ASSET]).toBeDefined(); @@ -575,6 +585,7 @@ describe('TokenDataSource', () => { includeMetadata: true, includeRwaData: true, includeAggregators: true, + includeOccurrences: true, }, ); }); @@ -610,6 +621,7 @@ describe('TokenDataSource', () => { includeMetadata: true, includeRwaData: true, includeAggregators: true, + includeOccurrences: true, }, ); }); @@ -643,7 +655,265 @@ describe('TokenDataSource', () => { includeMetadata: true, includeRwaData: true, includeAggregators: true, + includeOccurrences: true, }, ); }); + + it('middleware filters out non-native assets with occurrences < 3', async () => { + const lowOccurrenceAsset = + 'eip155:1/erc20:0x1111111111111111111111111111111111111111' as Caip19AssetId; + + const { controller } = setupController({ + supportedNetworks: ['eip155:1'], + assetsResponse: [ + createMockAssetResponse(MOCK_TOKEN_ASSET, { occurrences: 5 }), + createMockAssetResponse(lowOccurrenceAsset, { occurrences: 2 }), + ], + }); + + const next = jest.fn().mockResolvedValue(undefined); + const context = createMiddlewareContext({ + response: { + detectedAssets: { + 'mock-account-id': [MOCK_TOKEN_ASSET, lowOccurrenceAsset], + }, + assetsBalance: { + 'mock-account-id': { + [MOCK_TOKEN_ASSET]: { amount: '100' }, + [lowOccurrenceAsset]: { amount: '50' }, + }, + }, + }, + }); + + await controller.assetsMiddleware(context, next); + + expect(context.response.assetsInfo?.[MOCK_TOKEN_ASSET]).toBeDefined(); + expect(context.response.assetsInfo?.[lowOccurrenceAsset]).toBeUndefined(); + + const accountBalances = context.response.assetsBalance?.[ + 'mock-account-id' + ] as Record | undefined; + expect(accountBalances?.[MOCK_TOKEN_ASSET]).toBeDefined(); + expect(accountBalances?.[lowOccurrenceAsset]).toBeUndefined(); + + expect(context.response.detectedAssets?.['mock-account-id']).toContain( + MOCK_TOKEN_ASSET, + ); + expect(context.response.detectedAssets?.['mock-account-id']).not.toContain( + lowOccurrenceAsset, + ); + }); + + it('middleware filters out non-native assets with undefined occurrences', async () => { + const noOccurrenceAsset = + 'eip155:1/erc20:0x2222222222222222222222222222222222222222' as Caip19AssetId; + + const { controller } = setupController({ + supportedNetworks: ['eip155:1'], + assetsResponse: [ + createMockAssetResponse(noOccurrenceAsset, { occurrences: undefined }), + ], + }); + + const next = jest.fn().mockResolvedValue(undefined); + const context = createMiddlewareContext({ + response: { + detectedAssets: { + 'mock-account-id': [noOccurrenceAsset], + }, + }, + }); + + await controller.assetsMiddleware(context, next); + + expect(context.response.assetsInfo?.[noOccurrenceAsset]).toBeUndefined(); + }); + + it('middleware keeps native assets regardless of occurrences', async () => { + const { controller } = setupController({ + supportedNetworks: ['eip155:1'], + nativeAssetIds: [MOCK_NATIVE_ASSET], + assetsResponse: [ + createMockAssetResponse(MOCK_NATIVE_ASSET, { + name: 'Ethereum', + symbol: 'ETH', + occurrences: 0, + }), + ], + }); + + const next = jest.fn().mockResolvedValue(undefined); + const context = createMiddlewareContext({ + response: {}, + }); + + await controller.assetsMiddleware(context, next); + + expect(context.response.assetsInfo?.[MOCK_NATIVE_ASSET]).toBeDefined(); + expect(context.response.assetsInfo?.[MOCK_NATIVE_ASSET]?.type).toBe( + 'native', + ); + }); + + it('middleware always includes native asset IDs in the fetch', async () => { + const { controller, apiClient } = setupController({ + supportedNetworks: ['eip155:1'], + nativeAssetIds: [MOCK_NATIVE_ASSET], + assetsResponse: [ + createMockAssetResponse(MOCK_TOKEN_ASSET), + createMockAssetResponse(MOCK_NATIVE_ASSET, { + name: 'Ethereum', + symbol: 'ETH', + }), + ], + }); + + const next = jest.fn().mockResolvedValue(undefined); + const context = createMiddlewareContext({ + response: { + detectedAssets: { + 'mock-account-id': [MOCK_TOKEN_ASSET], + }, + }, + }); + + await controller.assetsMiddleware(context, next); + + expect(apiClient.tokens.fetchV3Assets).toHaveBeenCalledWith( + expect.arrayContaining([MOCK_TOKEN_ASSET, MOCK_NATIVE_ASSET]), + expect.objectContaining({ includeIconUrl: true }), + ); + expect(context.response.assetsInfo?.[MOCK_NATIVE_ASSET]).toBeDefined(); + expect(context.response.assetsInfo?.[MOCK_NATIVE_ASSET]?.type).toBe( + 'native', + ); + }); + + it('middleware fetches native asset IDs even when detectedAssets is undefined', async () => { + const { controller, apiClient } = setupController({ + supportedNetworks: ['eip155:1'], + nativeAssetIds: [MOCK_NATIVE_ASSET], + assetsResponse: [ + createMockAssetResponse(MOCK_NATIVE_ASSET, { + name: 'Ethereum', + symbol: 'ETH', + }), + ], + }); + + const next = jest.fn().mockResolvedValue(undefined); + // detectedAssets is intentionally omitted (undefined) to mirror the real-world + // case where DetectionMiddleware finds zero balances and zero custom assets + const context = createMiddlewareContext({ + response: {}, + }); + + await controller.assetsMiddleware(context, next); + + expect(apiClient.tokens.fetchV3Assets).toHaveBeenCalledWith( + [MOCK_NATIVE_ASSET], + expect.objectContaining({ includeIconUrl: true }), + ); + expect(context.response.assetsInfo?.[MOCK_NATIVE_ASSET]).toBeDefined(); + }); + + it('middleware fetches native asset IDs when detectedAssets is an empty object', async () => { + const { controller, apiClient } = setupController({ + supportedNetworks: ['eip155:1'], + nativeAssetIds: [MOCK_NATIVE_ASSET], + assetsResponse: [ + createMockAssetResponse(MOCK_NATIVE_ASSET, { + name: 'Ethereum', + symbol: 'ETH', + }), + ], + }); + + const next = jest.fn().mockResolvedValue(undefined); + const context = createMiddlewareContext({ + response: { + detectedAssets: { + 'mock-account-id': [], + }, + }, + }); + + await controller.assetsMiddleware(context, next); + + expect(apiClient.tokens.fetchV3Assets).toHaveBeenCalledWith( + [MOCK_NATIVE_ASSET], + expect.objectContaining({ includeIconUrl: true }), + ); + expect(context.response.assetsInfo?.[MOCK_NATIVE_ASSET]).toBeDefined(); + }); + + it('middleware deduplicates native asset IDs with detected assets', async () => { + const { controller, apiClient } = setupController({ + supportedNetworks: ['eip155:1'], + nativeAssetIds: [MOCK_NATIVE_ASSET], + assetsResponse: [ + createMockAssetResponse(MOCK_NATIVE_ASSET, { + name: 'Ethereum', + symbol: 'ETH', + }), + ], + }); + + const next = jest.fn().mockResolvedValue(undefined); + const context = createMiddlewareContext({ + response: { + detectedAssets: { + 'mock-account-id': [MOCK_NATIVE_ASSET], + }, + }, + }); + + await controller.assetsMiddleware(context, next); + + expect(apiClient.tokens.fetchV3Assets).toHaveBeenCalledWith( + [MOCK_NATIVE_ASSET], + expect.objectContaining({ includeIconUrl: true }), + ); + }); + + it('middleware includes multiple native asset IDs across chains', async () => { + const polygonNativeAsset = 'eip155:137/slip44:966' as Caip19AssetId; + + const { controller, apiClient } = setupController({ + supportedNetworks: ['eip155:1', 'eip155:137'], + nativeAssetIds: [MOCK_NATIVE_ASSET, polygonNativeAsset], + assetsResponse: [ + createMockAssetResponse(MOCK_NATIVE_ASSET, { + name: 'Ethereum', + symbol: 'ETH', + }), + createMockAssetResponse(polygonNativeAsset, { + name: 'Polygon', + symbol: 'POL', + }), + ], + }); + + const next = jest.fn().mockResolvedValue(undefined); + const context = createMiddlewareContext({ + response: { + detectedAssets: { + 'mock-account-id': [MOCK_TOKEN_ASSET], + }, + }, + }); + + await controller.assetsMiddleware(context, next); + + expect(apiClient.tokens.fetchV3Assets).toHaveBeenCalledWith( + expect.arrayContaining([ + MOCK_TOKEN_ASSET, + MOCK_NATIVE_ASSET, + polygonNativeAsset, + ]), + expect.objectContaining({ includeIconUrl: true }), + ); + }); }); diff --git a/packages/assets-controller/src/data-sources/TokenDataSource.ts b/packages/assets-controller/src/data-sources/TokenDataSource.ts index 1928113666a..8a7a742c7a6 100644 --- a/packages/assets-controller/src/data-sources/TokenDataSource.ts +++ b/packages/assets-controller/src/data-sources/TokenDataSource.ts @@ -19,6 +19,8 @@ import type { const CONTROLLER_NAME = 'TokenDataSource'; +const MIN_TOKEN_OCCURRENCES = 3; + const log = createModuleLogger(projectLogger, CONTROLLER_NAME); // ============================================================================ @@ -38,6 +40,8 @@ export type TokenDataSourceAllowedActions = never; export type TokenDataSourceOptions = { /** ApiPlatformClient for API calls with caching */ queryApiClient: ApiPlatformClient; + /** Returns CAIP-19 native asset IDs from NetworkEnablementController state */ + getNativeAssetIds: () => string[]; }; // ============================================================================ @@ -117,8 +121,12 @@ export class TokenDataSource { /** ApiPlatformClient for cached API calls */ readonly #apiClient: ApiPlatformClient; + /** Returns CAIP-19 native asset IDs from NetworkEnablementController state */ + readonly #getNativeAssetIds: () => string[]; + constructor(options: TokenDataSourceOptions) { this.#apiClient = options.queryApiClient; + this.#getNativeAssetIds = options.getNativeAssetIds; } /** @@ -185,34 +193,37 @@ export class TokenDataSource { // Extract response from context const { response } = ctx; - // Only fetch metadata for detected assets (assets without metadata) - if (!response.detectedAssets) { - return next(ctx); - } - const { assetsInfo: stateMetadata } = ctx.getAssetsState(); const assetIdsNeedingMetadata = new Set(); - for (const detectedIds of Object.values(response.detectedAssets)) { - for (const assetId of detectedIds) { - // Skip if response already has metadata with image - const responseMetadata = response.assetsInfo?.[assetId]; - if (responseMetadata?.image) { - continue; - } - - // Skip if state already has metadata with image - const existingMetadata = stateMetadata[assetId]; - if (existingMetadata?.image) { - continue; - } + // Always include native asset IDs from NetworkEnablementController + for (const nativeAssetId of this.#getNativeAssetIds()) { + assetIdsNeedingMetadata.add(nativeAssetId); + } - // Skip staking contracts; we use built-in metadata and do not fetch from the tokens API - if (isStakingContractAssetId(assetId)) { - continue; + // Also fetch metadata for detected assets that are missing it + if (response.detectedAssets) { + for (const detectedIds of Object.values(response.detectedAssets)) { + for (const assetId of detectedIds) { + // Skip if response already has metadata with image + const responseMetadata = response.assetsInfo?.[assetId]; + if (responseMetadata?.image) { + continue; + } + + // Skip if state already has metadata with image + const existingMetadata = stateMetadata[assetId]; + if (existingMetadata?.image) { + continue; + } + + // Skip staking contracts; we use built-in metadata and do not fetch from the tokens API + if (isStakingContractAssetId(assetId)) { + continue; + } + + assetIdsNeedingMetadata.add(assetId); } - - assetIdsNeedingMetadata.add(assetId); } } @@ -243,18 +254,54 @@ export class TokenDataSource { includeLabels: true, includeRwaData: true, includeAggregators: true, + includeOccurrences: true, }, ); response.assetsInfo ??= {}; + const filteredOutAssets = new Set(); + for (const assetData of metadataResponse) { + const parsed = parseCaipAssetType(assetData.assetId as CaipAssetType); + const isNative = parsed.assetNamespace === 'slip44'; + + if ( + !isNative && + (assetData.occurrences ?? 0) < MIN_TOKEN_OCCURRENCES + ) { + filteredOutAssets.add(assetData.assetId); + continue; + } + const caipAssetId = assetData.assetId as Caip19AssetId; response.assetsInfo[caipAssetId] = transformV3AssetResponseToMetadata( assetData.assetId, assetData, ); } + + if (filteredOutAssets.size > 0) { + if (response.assetsBalance) { + for (const accountBalances of Object.values( + response.assetsBalance, + )) { + for (const assetId of filteredOutAssets) { + delete (accountBalances as Record)[assetId]; + } + } + } + + if (response.detectedAssets) { + for (const [accountId, assetIds] of Object.entries( + response.detectedAssets, + )) { + response.detectedAssets[accountId] = assetIds.filter( + (id) => !filteredOutAssets.has(id), + ); + } + } + } } catch (error) { log('Failed to fetch metadata', { error }); } diff --git a/packages/core-backend/CHANGELOG.md b/packages/core-backend/CHANGELOG.md index 04774889465..a9921bf768f 100644 --- a/packages/core-backend/CHANGELOG.md +++ b/packages/core-backend/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `includeOccurrences` option to `V3AssetsQueryOptions` so the tokens v3 assets API can be called with `includeOccurrences: true` to return token list occurrence counts in the response ([#8227](https://github.com/MetaMask/core/pull/8227)) + ## [6.1.1] ### Changed diff --git a/packages/core-backend/src/api/tokens/types.ts b/packages/core-backend/src/api/tokens/types.ts index 9b9cdb85b04..d13385eb3cb 100644 --- a/packages/core-backend/src/api/tokens/types.ts +++ b/packages/core-backend/src/api/tokens/types.ts @@ -36,6 +36,8 @@ export type V3AssetsQueryOptions = { includeRwaData?: boolean; /** Include DEX/aggregator integrations in response */ includeAggregators?: boolean; + /** Include token list occurrences in response */ + includeOccurrences?: boolean; }; // ============================================================================