From d0db9f813cc8d5b9468ad5549d3ee5af1aabe1a4 Mon Sep 17 00:00:00 2001 From: salimtb Date: Fri, 31 Jul 2026 12:27:23 +0200 Subject: [PATCH] fix(assets-controller): exempt user-initiated activity from spam filtering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Occurrence-floor / Blockaid spam filtering now applies only to passively discovered tokens (auto-detection, incoming-only websocket transfers such as airdrops). Assets acquired through user-initiated activity — the account sent funds in the same transaction, e.g. swap outputs — are listed on a new DataResponse.userInteractedAssets field by AccountActivityDataSource and bypass the filter in TokenDataSource, so they always show even when below the per-chain occurrence floor. Filtered-out spam is now also stripped from stub assetsInfo on the response, and balance deletions match asset IDs case-insensitively. Co-authored-by: Cursor --- packages/assets-controller/CHANGELOG.md | 7 + .../AccountActivityDataSource.test.ts | 132 ++++++++++++++ .../data-sources/AccountActivityDataSource.ts | 37 +++- .../src/data-sources/TokenDataSource.test.ts | 163 ++++++++++++++++++ .../src/data-sources/TokenDataSource.ts | 51 +++++- packages/assets-controller/src/types.ts | 9 + 6 files changed, 389 insertions(+), 10 deletions(-) diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index 35f8e9bef2f..8c6f25a5b8d 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -15,6 +15,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **BREAKING:** Remove `BackendWebsocketDataSource` and its factory/types (`BackendWebsocketDataSource`, `createBackendWebsocketDataSource`, `BackendWebsocketDataSourceOptions`, `BackendWebsocketDataSourceState`). Real-time balance updates and per-chain status are now consumed from `AccountActivityService` via `AccountActivityDataSource`, which manages the WebSocket connection and subscriptions. Consumers no longer need to delegate `BackendWebSocketService` actions/events to the `AssetsController` messenger ([#9517](https://github.com/MetaMask/core/pull/9517)) +### Fixed + +- Exempt assets acquired through user-initiated activity from occurrence-floor / Blockaid spam filtering while keeping the filter for passive discovery (auto-detection, airdrops): + - `AccountActivityDataSource` inspects websocket transfers and lists assets on a new `DataResponse.userInteractedAssets` field when the account sent funds in the same transaction (e.g. a swap), so swap outputs always show even for brand-new tokens below the floor + - `TokenDataSource` bypasses spam filtering for `userInteractedAssets` (like custom assets); incoming-only websocket updates (spam airdrops) remain subject to the occurrence floor + - Filtered-out spam is now also removed from stub `assetsInfo` on the response, and balance deletions match asset IDs case-insensitively + ## [11.3.1] ### Changed diff --git a/packages/assets-controller/src/data-sources/AccountActivityDataSource.test.ts b/packages/assets-controller/src/data-sources/AccountActivityDataSource.test.ts index 2d522e7f681..c59b3419a56 100644 --- a/packages/assets-controller/src/data-sources/AccountActivityDataSource.test.ts +++ b/packages/assets-controller/src/data-sources/AccountActivityDataSource.test.ts @@ -354,6 +354,138 @@ describe('AccountActivityDataSource', () => { cleanup(); }); + it('marks assets as userInteractedAssets when the account sent funds in the message', async () => { + // Swap: the account pays USDC and receives NEWTOKEN in the same tx. + // Both assets must be exempt from spam filtering downstream. + const paidAsset = + 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Caip19AssetId; + const receivedAsset = + 'eip155:1/erc20:0x1111111111111111111111111111111111111111' as Caip19AssetId; + const account = createMockAccount(); + const { onAssetsUpdate, triggerBalanceUpdated, cleanup } = setup({ + groupAccounts: [account], + getAssetType: () => 'erc20', + }); + + triggerBalanceUpdated({ + address: EVM_ADDRESS, + chain: CHAIN_MAINNET, + updates: [ + createBalanceUpdate({ + asset: { type: paidAsset, unit: 'USDC', decimals: 6 }, + postBalance: { amount: '0' }, + transfers: [ + { from: EVM_ADDRESS, to: '0xpool', amount: '1000000' }, + ], + }), + createBalanceUpdate({ + asset: { type: receivedAsset, unit: 'NEW', decimals: 18 }, + postBalance: { amount: '1000000000000000000' }, + transfers: [ + { from: '0xpool', to: EVM_ADDRESS, amount: '1000000000000000000' }, + ], + }), + ], + }); + + await Promise.resolve(); + + expect(onAssetsUpdate).toHaveBeenCalledTimes(1); + const [response] = onAssetsUpdate.mock.calls[0]; + expect(response.userInteractedAssets).toStrictEqual([ + paidAsset, + receivedAsset, + ]); + + cleanup(); + }); + + it('does not mark userInteractedAssets for incoming-only transfers (airdrops)', async () => { + const airdroppedAsset = + 'eip155:1/erc20:0x2222222222222222222222222222222222222222' as Caip19AssetId; + const account = createMockAccount(); + const { onAssetsUpdate, triggerBalanceUpdated, cleanup } = setup({ + groupAccounts: [account], + getAssetType: () => 'erc20', + }); + + triggerBalanceUpdated({ + address: EVM_ADDRESS, + chain: CHAIN_MAINNET, + updates: [ + createBalanceUpdate({ + asset: { type: airdroppedAsset, unit: 'SPAM', decimals: 18 }, + postBalance: { amount: '1000000000000000000' }, + transfers: [ + { + from: '0x9999999999999999999999999999999999999999', + to: EVM_ADDRESS, + amount: '1000000000000000000', + }, + ], + }), + ], + }); + + await Promise.resolve(); + + expect(onAssetsUpdate).toHaveBeenCalledTimes(1); + const [response] = onAssetsUpdate.mock.calls[0]; + expect(response.userInteractedAssets).toBeUndefined(); + expect(response.assetsBalance[account.id][airdroppedAsset]).toBeDefined(); + + cleanup(); + }); + + it('seeds stub assetsInfo for erc20 tokens from websocket updates', async () => { + const erc20Asset = + 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Caip19AssetId; + const account = createMockAccount(); + const { onAssetsUpdate, triggerBalanceUpdated, cleanup } = setup({ + groupAccounts: [account], + getAssetType: () => 'erc20', + }); + + triggerBalanceUpdated({ + address: EVM_ADDRESS, + chain: CHAIN_MAINNET, + updates: [ + createBalanceUpdate({ + asset: { + type: erc20Asset, + unit: 'USDC', + decimals: 6, + }, + postBalance: { amount: '1000000' }, + }), + ], + }); + + await Promise.resolve(); + + expect(onAssetsUpdate).toHaveBeenCalledTimes(1); + const [response] = onAssetsUpdate.mock.calls[0]; + // eslint-disable-next-line jest/prefer-strict-equal + expect(response).toEqual({ + updateMode: 'merge', + assetsBalance: { + [account.id]: { + [erc20Asset]: { amount: '1' }, + }, + }, + assetsInfo: { + [erc20Asset]: { + type: 'erc20', + symbol: 'USDC', + name: 'USDC', + decimals: 6, + }, + }, + }); + + cleanup(); + }); + it.each([ ['address is empty', { address: '' }], ['chain is empty', { chain: '' }], diff --git a/packages/assets-controller/src/data-sources/AccountActivityDataSource.ts b/packages/assets-controller/src/data-sources/AccountActivityDataSource.ts index 13b701d80f5..396e3d379c5 100644 --- a/packages/assets-controller/src/data-sources/AccountActivityDataSource.ts +++ b/packages/assets-controller/src/data-sources/AccountActivityDataSource.ts @@ -32,18 +32,48 @@ const log = createModuleLogger(projectLogger, CONTROLLER_NAME); // BALANCE UPDATE PROCESSING // ============================================================================ +/** + * Whether the account actively sent funds in this activity message — i.e. the + * update was caused by a transaction the user participated in (swap, payment) + * rather than a passive incoming transfer (airdrop, unsolicited spam send). + * + * @param updates - Balance updates from account-activity websocket payload. + * @param address - The account address the message is for. + * @returns True when any transfer in the message has the account as sender. + */ +function isUserInitiatedActivity( + updates: BalanceUpdate[], + address: string, +): boolean { + const addressLower = address.toLowerCase(); + return updates.some((update) => + update.transfers?.some( + (transfer) => transfer.from?.toLowerCase() === addressLower, + ), + ); +} + /** * Convert AccountActivityMessage balance updates into a {@link DataResponse} * for AssetsController. * + * When the message is user-initiated (the account sent funds in the same + * transaction — see {@link isUserInitiatedActivity}), its assets are listed + * on `userInteractedAssets` so TokenDataSource exempts them from + * occurrence-floor / Blockaid spam filtering (a swap output chosen by the + * user must always show, even for a brand-new token). Passive incoming + * transfers get no exemption, so spam airdrops are still filtered. + * * @param updates - Balance updates from account-activity websocket payload. * @param accountId - Internal account UUID. + * @param address - The account address the message is for. * @param getAssetType - Resolver for asset metadata type. * @returns DataResponse with merge mode when balances are present. */ function processAccountActivityBalanceUpdates( updates: BalanceUpdate[], accountId: string, + address: string, getAssetType: (assetId: Caip19AssetId) => 'native' | 'erc20' | 'spl', ): DataResponse { const assetsBalance = Object.create(null) as Record< @@ -93,9 +123,13 @@ function processAccountActivityBalanceUpdates( } const response: DataResponse = { updateMode: 'merge' }; - if (Object.keys(assetsBalance[accountId]).length > 0) { + const assetIds = Object.keys(assetsBalance[accountId]) as Caip19AssetId[]; + if (assetIds.length > 0) { response.assetsBalance = assetsBalance; response.assetsInfo = assetsMetadata; + if (isUserInitiatedActivity(updates, address)) { + response.userInteractedAssets = assetIds; + } } return response; @@ -285,6 +319,7 @@ export class AccountActivityDataSource extends AbstractDataSource< const response = processAccountActivityBalanceUpdates( updates, account.id, + address, (assetId) => this.#getAssetType(assetId), ); diff --git a/packages/assets-controller/src/data-sources/TokenDataSource.test.ts b/packages/assets-controller/src/data-sources/TokenDataSource.test.ts index 68f7ff02480..a10d2579dc9 100644 --- a/packages/assets-controller/src/data-sources/TokenDataSource.test.ts +++ b/packages/assets-controller/src/data-sources/TokenDataSource.test.ts @@ -392,6 +392,61 @@ describe('TokenDataSource', () => { ); }); + it('middleware strips stub assetsInfo and case-mismatched balances for filtered spam', async () => { + // Websocket stubs may leave assetsInfo without image, and balance keys may + // differ in address casing from the Token API assetId. + const spamChecksum = + 'eip155:1/erc20:0x2222222222222222222222222222222222222222' as Caip19AssetId; + const spamLower = + 'eip155:1/erc20:0x2222222222222222222222222222222222222222' as Caip19AssetId; + + const { controller } = setupController({ + messenger: createTestMessenger(), + supportedNetworks: ['eip155:1'], + assetsResponse: [ + createMockAssetResponse(spamLower, { occurrences: 1 }), + ], + suggestedOccurrenceFloors: { '1': 3 }, + }); + + const next = jest.fn().mockResolvedValue(undefined); + const context = createMiddlewareContext({ + response: { + detectedAssets: { + 'mock-account-id': [spamChecksum], + }, + assetsBalance: { + 'mock-account-id': { + [spamChecksum]: { amount: '50' }, + }, + }, + assetsInfo: { + [spamChecksum]: { + type: 'erc20', + name: 'SPAM', + symbol: 'SPAM', + decimals: 18, + }, + }, + }, + }); + + await controller.assetsMiddleware(context, next); + + expect(context.response.assetsInfo?.[spamChecksum]).toBeUndefined(); + expect(context.response.assetsInfo?.[spamLower]).toBeUndefined(); + expect( + ( + context.response.assetsBalance?.['mock-account-id'] as + | Record + | undefined + )?.[spamChecksum], + ).toBeUndefined(); + expect(context.response.detectedAssets?.['mock-account-id']).not.toContain( + spamChecksum, + ); + }); + it('middleware skips assets with existing metadata containing image in response', async () => { const { controller, apiClient } = setupController({ messenger: createTestMessenger(), @@ -974,6 +1029,114 @@ describe('TokenDataSource', () => { ); }); + it('middleware removes stub assetsInfo and case-mismatched balances for filtered spam', async () => { + // Websocket may leave a stub assetsInfo entry and a differently-cased + // balance key; filtering must clear both so spam cannot linger in state. + const spamAssetChecksummed = + 'eip155:1/erc20:0x1111111111111111111111111111111111111111' as Caip19AssetId; + const spamAssetLower = + 'eip155:1/erc20:0x1111111111111111111111111111111111111111' as Caip19AssetId; + + const { controller } = setupController({ + messenger: createTestMessenger(), + supportedNetworks: ['eip155:1'], + assetsResponse: [ + createMockAssetResponse(spamAssetLower, { occurrences: 1 }), + ], + suggestedOccurrenceFloors: { '1': 3 }, + }); + + const next = jest.fn().mockResolvedValue(undefined); + const context = createMiddlewareContext({ + response: { + detectedAssets: { + 'mock-account-id': [spamAssetChecksummed], + }, + assetsBalance: { + 'mock-account-id': { + [spamAssetChecksummed]: { amount: '50' }, + }, + }, + assetsInfo: { + [spamAssetChecksummed]: { + type: 'erc20', + symbol: 'SPAM', + name: 'SPAM', + decimals: 18, + }, + }, + }, + }); + + await controller.assetsMiddleware(context, next); + + expect(context.response.assetsInfo?.[spamAssetChecksummed]).toBeUndefined(); + expect( + ( + context.response.assetsBalance?.['mock-account-id'] as + | Record + | undefined + )?.[spamAssetChecksummed], + ).toBeUndefined(); + expect(context.response.detectedAssets?.['mock-account-id']).not.toContain( + spamAssetChecksummed, + ); + }); + + it('middleware skips occurrence filter for user-interacted assets (e.g. swap outputs)', async () => { + // Assets on response.userInteractedAssets were acquired through + // user-initiated activity and must keep balance and metadata even when + // below the occurrence floor (passive detection still filters). + const lowOccurrenceAsset = + 'eip155:1/erc20:0x1111111111111111111111111111111111111111' as Caip19AssetId; + + const { controller } = setupController({ + messenger: createTestMessenger(), + supportedNetworks: ['eip155:1'], + assetsResponse: [ + createMockAssetResponse(lowOccurrenceAsset, { occurrences: 1 }), + ], + suggestedOccurrenceFloors: { '1': 3 }, + }); + + const next = jest.fn().mockResolvedValue(undefined); + const context = createMiddlewareContext({ + response: { + userInteractedAssets: [lowOccurrenceAsset], + detectedAssets: { + 'mock-account-id': [lowOccurrenceAsset], + }, + assetsBalance: { + 'mock-account-id': { + [lowOccurrenceAsset]: { amount: '50' }, + }, + }, + assetsInfo: { + [lowOccurrenceAsset]: { + type: 'erc20', + symbol: 'SWAP', + name: 'SWAP', + decimals: 18, + }, + }, + }, + }); + + await controller.assetsMiddleware(context, next); + + expect(context.response.assetsInfo?.[lowOccurrenceAsset]).toBeDefined(); + expect( + ( + context.response.assetsBalance?.['mock-account-id'] as + | Record + | undefined + )?.[lowOccurrenceAsset], + ).toBeDefined(); + expect(context.response.detectedAssets?.['mock-account-id']).toContain( + lowOccurrenceAsset, + ); + }); + 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 5d602b9a8d3..2c338d35c9e 100644 --- a/packages/assets-controller/src/data-sources/TokenDataSource.ts +++ b/packages/assets-controller/src/data-sources/TokenDataSource.ts @@ -358,9 +358,16 @@ export class TokenDataSource { * 4. Calls next() at the end to continue the middleware chain * * Spam filtering (EVM occurrence floors / non-EVM Blockaid) applies only to - * newly `detectedAssets`. Balance-only heals — assets already present in - * `assetsBalance` but missing `assetsInfo`, which DetectionMiddleware skips — - * are enriched without being filtered out of balances. + * newly `detectedAssets` from passive discovery (auto-detection, airdrops). + * It is skipped when: + * - the asset is a balance-only heal (already in `assetsBalance`, Detection skipped), + * - the asset is a user-imported custom asset, or + * - the asset is listed on `response.userInteractedAssets` — acquired through + * user-initiated activity (e.g. a swap output), so it must always show. + * + * Filtered-out detected assets are removed from `assetsBalance`, + * `detectedAssets`, and any stub `assetsInfo` already on the response so spam + * cannot linger in state. * * @returns The middleware function for the assets pipeline. */ @@ -387,6 +394,14 @@ export class TokenDataSource { .map((id) => id.toLowerCase()), ); + // Assets acquired through user-initiated on-chain activity (the user + // sent funds in the same transaction, e.g. swap outputs reported by the + // account-activity websocket). Exempt from spam filtering like custom + // assets — the user chose to acquire them. + const userInteractedAssetIds = new Set( + (response.userInteractedAssets ?? []).map((id) => id.toLowerCase()), + ); + // Always include native asset IDs from NetworkEnablementController const nativeAssetIdsList = this.#getNativeAssetIds(); const nativeAssetIds = new Set( @@ -526,11 +541,13 @@ export class TokenDataSource { // can import whatever they want and we must keep their metadata even // if the API has fewer aggregator hits than the floor. // Balance-only heals also bypass — see `balanceHealAssetIds` below. + // User-initiated acquisitions bypass — see `userInteractedAssetIds`. const allowedEvmIds = new Set( evmErc20Ids.filter( (id) => customAssetIds.has(id.toLowerCase()) || balanceHealAssetIds.has(id.toLowerCase()) || + userInteractedAssetIds.has(id.toLowerCase()) || (occurrencesByAssetId.get(id) ?? 0) >= getOccurrenceFloorForAsset(id, suggestedOccurrenceFloors) || id.includes(`/erc20:${MUSD_ADDRESS_LOWERCASE}`), @@ -538,17 +555,19 @@ export class TokenDataSource { ); // Non-EVM: Blockaid bulk scan. - // Custom assets and balance-only heals bypass Blockaid filtering. + // Custom assets, balance-only heals, and user-initiated acquisitions bypass. const nonEvmToScan = nonEvmTokenIds.filter( (id) => !customAssetIds.has(id.toLowerCase()) && - !balanceHealAssetIds.has(id.toLowerCase()), + !balanceHealAssetIds.has(id.toLowerCase()) && + !userInteractedAssetIds.has(id.toLowerCase()), ); const allowedNonEvmIds = new Set([ ...nonEvmTokenIds.filter( (id) => customAssetIds.has(id.toLowerCase()) || - balanceHealAssetIds.has(id.toLowerCase()), + balanceHealAssetIds.has(id.toLowerCase()) || + userInteractedAssetIds.has(id.toLowerCase()), ), ...(await this.#filterBlockaidSpamTokens(nonEvmToScan)), ]); @@ -572,10 +591,12 @@ export class TokenDataSource { response.assetsInfo ??= {}; const filteredOutAssets = new Set(); + const filteredOutAssetsLower = new Set(); for (const assetData of metadataResponse) { if (!allowedAssetIds.has(assetData.assetId)) { filteredOutAssets.add(assetData.assetId); + filteredOutAssetsLower.add(assetData.assetId.toLowerCase()); continue; } @@ -592,8 +613,20 @@ export class TokenDataSource { for (const accountBalances of Object.values( response.assetsBalance, )) { - for (const assetId of filteredOutAssets) { - delete (accountBalances as Record)[assetId]; + for (const assetId of Object.keys( + accountBalances as Record, + )) { + if (filteredOutAssetsLower.has(assetId.toLowerCase())) { + delete (accountBalances as Record)[assetId]; + } + } + } + } + + if (response.assetsInfo) { + for (const assetId of Object.keys(response.assetsInfo)) { + if (filteredOutAssetsLower.has(assetId.toLowerCase())) { + delete response.assetsInfo[assetId as Caip19AssetId]; } } } @@ -603,7 +636,7 @@ export class TokenDataSource { response.detectedAssets, )) { response.detectedAssets[accountId] = assetIds.filter( - (id) => !filteredOutAssets.has(id), + (id) => !filteredOutAssetsLower.has(id.toLowerCase()), ); } } diff --git a/packages/assets-controller/src/types.ts b/packages/assets-controller/src/types.ts index 2b2b940a713..74b3ea035c9 100644 --- a/packages/assets-controller/src/types.ts +++ b/packages/assets-controller/src/types.ts @@ -380,6 +380,15 @@ export type DataResponse = { * without switching to `updateMode: 'full'`. */ replaceCoveredChainBalances?: boolean; + /** + * Assets acquired through user-initiated on-chain activity — the user sent + * funds in the same transaction (e.g. swap outputs from account-activity + * websocket updates). TokenDataSource exempts these from occurrence-floor / + * Blockaid spam filtering, like custom assets. Passive incoming transfers + * (airdrops) are never listed here and remain subject to spam filtering. + * Pipeline-only metadata: not persisted to state. + */ + userInteractedAssets?: Caip19AssetId[]; }; /**