diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index 5c4942dea4..3c5cd65b68 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Apply occurrence-floor spam filtering to tokens received through account-activity (websocket) updates: new tokens are enriched with Token API occurrence counts before detection and dropped when below the per-chain suggested floor, and stub metadata of filtered-out assets is stripped from the pipeline response so spam tokens never persist to state ([#9768](https://github.com/MetaMask/core/pull/9768)) - Preserve pooled-staking balances across Accounts API chain-slice updates (e.g. network switch / `replaceCoveredChainBalances`): exclude staking contract asset IDs from `AccountsApiDataSource` v5/v6 balance processing, and keep prior staked amounts when a merge replace omits them so Accounts API cannot reset staked ETH to missing/0 ([#9753](https://github.com/MetaMask/core/pull/9753)) ## [13.1.1] diff --git a/packages/assets-controller/src/AssetsController.ts b/packages/assets-controller/src/AssetsController.ts index cee1e2daa5..ed4cd54dc9 100644 --- a/packages/assets-controller/src/AssetsController.ts +++ b/packages/assets-controller/src/AssetsController.ts @@ -3874,10 +3874,26 @@ export class AssetsController extends BaseController< sourceId === 'AccountsApiDataSource' || sourceId === 'AccountActivityDataSource'; + // Websocket updates can carry brand-new spam airdrops: enrich them + // with Token API occurrences and drop below-floor tokens BEFORE + // detection, so spam is never detected, enriched, priced or persisted. + const shouldFilterOccurrences = + sourceId === 'AccountActivityDataSource' && + this.#isBasicFunctionality(); + const enrichmentSources: AssetsDataSource[] = [ ...(shouldGraduateCustomAssets ? [this.#customAssetGraduationMiddleware] : []), + ...(shouldFilterOccurrences + ? [ + { + getName: () => 'OccurrenceFloorFilter', + assetsMiddleware: + this.#tokenDataSource.occurrenceFilterMiddleware, + }, + ] + : []), this.#detectionMiddleware, ]; if (this.#isBasicFunctionality()) { diff --git a/packages/assets-controller/src/data-sources/TokenDataSource.test.ts b/packages/assets-controller/src/data-sources/TokenDataSource.test.ts index 68f7ff0248..58a69a0a90 100644 --- a/packages/assets-controller/src/data-sources/TokenDataSource.test.ts +++ b/packages/assets-controller/src/data-sources/TokenDataSource.test.ts @@ -974,6 +974,112 @@ describe('TokenDataSource', () => { ); }); + it('middleware strips stub metadata of filtered-out assets so it does not persist', async () => { + const spamAsset = + 'eip155:1/erc20:0x1111111111111111111111111111111111111111' as Caip19AssetId; + + const { controller } = setupController({ + messenger: createTestMessenger(), + supportedNetworks: ['eip155:1'], + assetsResponse: [createMockAssetResponse(spamAsset, { occurrences: 1 })], + suggestedOccurrenceFloors: { '1': 3 }, + }); + + const next = jest.fn().mockResolvedValue(undefined); + // Websocket-shaped update: detected asset with a seeded metadata stub + // (name/symbol, no image). If the stub survived filtering it would + // persist to state and mask re-detection of the spam token. + const context = createMiddlewareContext({ + response: { + detectedAssets: { + 'mock-account-id': [spamAsset], + }, + assetsBalance: { + 'mock-account-id': { + [spamAsset]: { amount: '50' }, + }, + }, + assetsInfo: { + [spamAsset]: { + type: 'erc20', + name: 'Spam Token', + symbol: 'SPAM', + decimals: 18, + }, + }, + }, + }); + + await controller.assetsMiddleware(context, next); + + expect( + ( + context.response.assetsBalance?.['mock-account-id'] as Record< + string, + unknown + > + )?.[spamAsset], + ).toBeUndefined(); + expect(context.response.assetsInfo?.[spamAsset]).toBeUndefined(); + }); + + it('occurrenceFilterMiddleware drops new low-occurrence tokens before detection', async () => { + const spamAsset = + 'eip155:1/erc20:0x1111111111111111111111111111111111111111' as Caip19AssetId; + + const { controller, apiClient } = setupController({ + messenger: createTestMessenger(), + supportedNetworks: ['eip155:1'], + assetsResponse: [ + createMockAssetResponse(MOCK_TOKEN_ASSET, { occurrences: 5 }), + createMockAssetResponse(spamAsset, { occurrences: 1 }), + ], + suggestedOccurrenceFloors: { '1': 3 }, + }); + + const next = jest.fn().mockResolvedValue(undefined); + // Websocket-shaped update: brand-new tokens (absent from state) with + // seeded metadata stubs. Runs BEFORE DetectionMiddleware. + const context = createMiddlewareContext({ + request: createDataRequest({ dataTypes: ['balance'] }), + response: { + assetsBalance: { + 'mock-account-id': { + [MOCK_TOKEN_ASSET]: { amount: '100' }, + [spamAsset]: { amount: '50' }, + }, + }, + assetsInfo: { + [spamAsset]: { + type: 'erc20', + name: 'Spam Token', + symbol: 'SPAM', + decimals: 18, + }, + }, + }, + getAssetsState: jest.fn().mockReturnValue({ + assetsBalance: {}, + assetsInfo: {}, + }), + }); + + await controller.occurrenceFilterMiddleware(context, next); + + expect(apiClient.tokens.fetchV3Assets).toHaveBeenCalledWith( + expect.arrayContaining([MOCK_TOKEN_ASSET, spamAsset]), + { includeOccurrences: true }, + ); + + const accountBalances = context.response.assetsBalance?.[ + 'mock-account-id' + ] as Record; + expect(accountBalances[MOCK_TOKEN_ASSET]).toBeDefined(); + expect(accountBalances[spamAsset]).toBeUndefined(); + expect(context.response.assetsInfo?.[spamAsset]).toBeUndefined(); + expect(next).toHaveBeenCalled(); + }); + it('middleware uses per-chain suggested occurrence floors from Token API', async () => { // Monad (143) suggests floor 1 — a token with occurrences=1 should pass. const monadToken = diff --git a/packages/assets-controller/src/data-sources/TokenDataSource.ts b/packages/assets-controller/src/data-sources/TokenDataSource.ts index 5d602b9a8d..fe20f3980e 100644 --- a/packages/assets-controller/src/data-sources/TokenDataSource.ts +++ b/packages/assets-controller/src/data-sources/TokenDataSource.ts @@ -348,6 +348,140 @@ export class TokenDataSource { return assets.filter((asset) => !rejectedAssets.has(asset)); } + /** + * Middleware that runs BEFORE DetectionMiddleware for account-activity + * (websocket) updates. New-to-state EVM ERC-20 balances are enriched with + * their Token API occurrence counts first, and tokens below the per-chain + * suggested occurrence floor are dropped from the response (balances and + * stub metadata) so spam airdrops never reach detection, metadata + * enrichment, pricing, or state. Custom assets and mUSD are exempt, and + * assets unknown to the Token API are kept (fail open), mirroring + * {@link TokenDataSource.assetsMiddleware} filtering semantics. + * + * @returns The middleware function for the assets pipeline. + */ + get occurrenceFilterMiddleware(): Middleware { + return forDataTypes(['balance'], async (ctx, next) => { + const { response } = ctx; + const { + assetsBalance: stateBalances, + assetsInfo: stateMetadata, + customAssets, + } = ctx.getAssetsState(); + + const customAssetIds = new Set( + Object.values(customAssets ?? {}) + .flat() + .map((id) => id.toLowerCase()), + ); + + // Candidates: EVM ERC-20s that are genuinely new (absent from state + // balances and metadata) — the same assets DetectionMiddleware would + // mark as newly detected right after this middleware. + const candidateByLowerId = new Map(); + for (const [accountId, accountBalances] of Object.entries( + response.assetsBalance ?? {}, + )) { + const stateAccountBalances = stateBalances[accountId] ?? {}; + for (const assetId of Object.keys(accountBalances)) { + const caipAssetId = assetId as Caip19AssetId; + const lowerId = assetId.toLowerCase(); + if ( + stateAccountBalances[caipAssetId] !== undefined || + stateMetadata[caipAssetId] !== undefined || + customAssetIds.has(lowerId) || + lowerId.includes(`/erc20:${MUSD_ADDRESS_LOWERCASE}`) + ) { + continue; + } + try { + const { assetNamespace, chain } = parseCaipAssetType(caipAssetId); + if ( + assetNamespace === CaipAssetNamespace.Erc20 && + chain.namespace === KnownCaipNamespace.Eip155 + ) { + candidateByLowerId.set(lowerId, assetId); + } + } catch { + // Unparseable IDs are left for downstream middleware to handle. + } + } + } + + if (candidateByLowerId.size === 0) { + return next(ctx); + } + + try { + const [occurrenceResponse, suggestedOccurrenceFloors] = + await Promise.all([ + reduceInBatchesSerially({ + values: [...candidateByLowerId.values()], + batchSize: TOKENS_API_BATCH_SIZE, + eachBatch: async (workingResult, batch) => { + const batchResponse = await fetchWithTimeout( + () => + this.#apiClient.tokens.fetchV3Assets(batch, { + includeOccurrences: true, + }), + this.#fetchTimeoutMs, + ); + return [ + ...(workingResult as V3AssetResponse[]), + ...batchResponse, + ]; + }, + initialResult: [], + }), + this.#getSuggestedOccurrenceFloors(), + ]); + + // Only assets the API knows can be judged; missing ones are kept. + const spamAssetIds = new Set(); + for (const assetData of occurrenceResponse) { + const candidateId = candidateByLowerId.get( + assetData.assetId.toLowerCase(), + ); + if ( + candidateId !== undefined && + (assetData.occurrences ?? 0) < + getOccurrenceFloorForAsset(candidateId, suggestedOccurrenceFloors) + ) { + spamAssetIds.add(candidateId); + } + } + + if (spamAssetIds.size > 0) { + for (const accountBalances of Object.values( + response.assetsBalance ?? {}, + )) { + for (const assetId of spamAssetIds) { + delete (accountBalances as Record)[assetId]; + } + } + if (response.assetsInfo) { + const spamLowerIds = new Set( + [...spamAssetIds].map((id) => id.toLowerCase()), + ); + for (const assetId of Object.keys(response.assetsInfo)) { + if (spamLowerIds.has(assetId.toLowerCase())) { + delete response.assetsInfo[assetId as Caip19AssetId]; + } + } + } + log('Filtered low-occurrence websocket assets', { + assetIds: [...spamAssetIds], + }); + } + } catch (error) { + // Fail open — keep all assets when occurrences cannot be fetched. + log('Failed to fetch occurrences for websocket update', { error }); + } + + return next(ctx); + }); + } + /** * Get the middleware for enriching responses with token metadata. * @@ -607,6 +741,20 @@ export class TokenDataSource { ); } } + + // Drop stub metadata (e.g. websocket-seeded name/symbol) for + // filtered-out assets so it never persists to state — a persisted + // stub would make the asset look "known" on the next update and + // let its balance skip spam filtering as a heal. Case-insensitive + // because the API may return asset IDs in a different case. + const filteredOutLower = new Set( + [...filteredOutAssets].map((id) => id.toLowerCase()), + ); + for (const assetId of Object.keys(response.assetsInfo)) { + if (filteredOutLower.has(assetId.toLowerCase())) { + delete response.assetsInfo[assetId as Caip19AssetId]; + } + } } } catch (error) { log('Failed to fetch metadata', { error });