diff --git a/eslint-suppressions.json b/eslint-suppressions.json index c32201a64e..c7e99f9bf4 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -157,7 +157,7 @@ }, "packages/assets-controller/src/AssetsController.ts": { "no-restricted-syntax": { - "count": 3 + "count": 4 } }, "packages/assets-controller/src/__fixtures__/MockAssetControllerMessenger.ts": { diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index aefa1d0c02..2944c7bca3 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -7,8 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `DetectionMiddlewareOptions` with an `isTokenDetectionEnabled` callback to `DetectionMiddleware`. `AssetsController` wires it from the `useTokenDetection` preference read via `PreferencesController:getState` (defaulting to enabled when `PreferencesController` is not registered), so when the user's token-autodetection preference is off, new-to-state fungible tokens (`erc20` and `token` namespaces, e.g. ERC-20 and SPL) are neither detected nor persisted from any pipeline, including websocket (account-activity) updates — their balances and stub metadata are stripped from the response. Native assets, staking-contract assets, custom (user-imported) assets, and holdings already tracked in state are unaffected ([#9835](https://github.com/MetaMask/core/pull/9835)) + - `AssetsController` also subscribes to `PreferencesController:stateChange` and force-refreshes balances, metadata, and prices when the preference is turned back on, so tokens skipped while it was off are detected without waiting for the next poll + ### Changed +- **BREAKING:** `AssetsControllerMessenger` now requires the `PreferencesController:getState` action to be allowed ([#9835](https://github.com/MetaMask/core/pull/9835)) + - `AssetsController` calls it to read the user's `useTokenDetection` preference; clients must add the action to the allowed actions when constructing the restricted messenger - Bump `@metamask/transaction-controller` from `^69.5.1` to `^69.5.2` ([#9823](https://github.com/MetaMask/core/pull/9823)) ## [13.1.2] diff --git a/packages/assets-controller/src/AssetsController.test.ts b/packages/assets-controller/src/AssetsController.test.ts index 075b220955..a2fe3ea549 100644 --- a/packages/assets-controller/src/AssetsController.test.ts +++ b/packages/assets-controller/src/AssetsController.test.ts @@ -824,6 +824,92 @@ describe('AssetsController', () => { }); }); + describe('token detection preference', () => { + const registerUseTokenDetection = ( + messenger: RootMessenger, + useTokenDetection: boolean, + ): void => { + ( + messenger as { + registerActionHandler: (a: string, h: () => unknown) => void; + } + ).registerActionHandler('PreferencesController:getState', () => ({ + useTokenDetection, + })); + }; + + // Built per test: the pipeline mutates the response in place when it + // strips gated tokens, so a shared object would leak across tests. + const newAssetsUpdate = (): DataResponse => ({ + assetsBalance: { + [MOCK_ACCOUNT_ID]: { + [MOCK_ASSET_ID]: { amount: '1000000' }, + [MOCK_NATIVE_ASSET_ID]: { amount: '2000000000000000000' }, + }, + }, + }); + + // isBasicFunctionality is disabled so the occurrence filter (which needs + // a real token API response) stays out of the way and the tests observe + // DetectionMiddleware's preference gating in isolation. + it('strips new fungible tokens but keeps natives when useTokenDetection is false', async () => { + await withController( + { isBasicFunctionality: () => false }, + async ({ controller, messenger }) => { + registerUseTokenDetection(messenger, false); + + await controller.handleAssetsUpdate( + newAssetsUpdate(), + 'AccountActivityDataSource', + ); + + expect( + controller.state.assetsBalance[MOCK_ACCOUNT_ID]?.[MOCK_ASSET_ID], + ).toBeUndefined(); + expect( + controller.state.assetsBalance[MOCK_ACCOUNT_ID]?.[ + MOCK_NATIVE_ASSET_ID + ], + ).toBeDefined(); + }, + ); + }); + + it('keeps new fungible tokens when useTokenDetection is true', async () => { + await withController( + { isBasicFunctionality: () => false }, + async ({ controller, messenger }) => { + registerUseTokenDetection(messenger, true); + + await controller.handleAssetsUpdate( + newAssetsUpdate(), + 'AccountActivityDataSource', + ); + + expect( + controller.state.assetsBalance[MOCK_ACCOUNT_ID]?.[MOCK_ASSET_ID], + ).toBeDefined(); + }, + ); + }); + + it('keeps new fungible tokens when PreferencesController is not registered (fail open)', async () => { + await withController( + { isBasicFunctionality: () => false }, + async ({ controller }) => { + await controller.handleAssetsUpdate( + newAssetsUpdate(), + 'AccountActivityDataSource', + ); + + expect( + controller.state.assetsBalance[MOCK_ACCOUNT_ID]?.[MOCK_ASSET_ID], + ).toBeDefined(); + }, + ); + }); + }); + describe('getCustomAssets', () => { it('returns empty array for account with no custom assets', async () => { await withController(({ controller }) => { @@ -2844,6 +2930,50 @@ describe('AssetsController', () => { }); }); + it('force refreshes assets when the token detection preference is turned on', async () => { + await withController(async ({ controller, messenger }) => { + const getAssetsSpy = jest + .spyOn(controller, 'getAssets') + .mockResolvedValue({}); + + (messenger.publish as CallableFunction)( + 'PreferencesController:stateChange', + { useTokenDetection: true }, + [], + ); + await flushPromises(); + + expect(getAssetsSpy).toHaveBeenCalledWith( + [expect.objectContaining({ id: MOCK_ACCOUNT_ID })], + { + forceUpdate: true, + dataTypes: ['balance', 'metadata', 'price'], + }, + ); + + getAssetsSpy.mockRestore(); + }); + }); + + it('does not refresh assets when the token detection preference is turned off', async () => { + await withController(async ({ controller, messenger }) => { + const getAssetsSpy = jest + .spyOn(controller, 'getAssets') + .mockResolvedValue({}); + + (messenger.publish as CallableFunction)( + 'PreferencesController:stateChange', + { useTokenDetection: false }, + [], + ); + await flushPromises(); + + expect(getAssetsSpy).not.toHaveBeenCalled(); + + getAssetsSpy.mockRestore(); + }); + }); + it('publishes balanceChanged event when balance updates', async () => { await withController(async ({ controller, messenger }) => { const balanceChangedHandler = jest.fn(); diff --git a/packages/assets-controller/src/AssetsController.ts b/packages/assets-controller/src/AssetsController.ts index ed4cd54dc9..d2ec0bf693 100644 --- a/packages/assets-controller/src/AssetsController.ts +++ b/packages/assets-controller/src/AssetsController.ts @@ -45,7 +45,10 @@ import type { PermissionControllerStateChange, } from '@metamask/permission-controller'; import { PhishingControllerBulkScanTokensAction } from '@metamask/phishing-controller'; -import type { PreferencesControllerStateChangeEvent } from '@metamask/preferences-controller'; +import type { + PreferencesControllerGetStateAction, + PreferencesControllerStateChangeEvent, +} from '@metamask/preferences-controller'; import type { RemoteFeatureFlagControllerGetStateAction, RemoteFeatureFlagControllerStateChangeEvent, @@ -339,7 +342,9 @@ type AllowedActions = // PhishingController | PhishingControllerBulkScanTokensAction // AccountsApiDataSource (Accounts API v6 balances feature flag) - | RemoteFeatureFlagControllerGetStateAction; + | RemoteFeatureFlagControllerGetStateAction + // DetectionMiddleware ("Autodetect tokens" preference) + | PreferencesControllerGetStateAction; type AllowedEvents = // AssetsController @@ -826,6 +831,23 @@ export class AssetsController extends BaseController< readonly #detectionMiddleware: DetectionMiddleware; + /** + * "Autodetect tokens" preference; fails open (enabled) on clients that + * don't register PreferencesController (e.g. mobile). + * + * @returns Whether token autodetection is enabled. + */ + readonly #tokenDetectionEnabled = (): boolean => { + try { + const preferencesState = this.messenger.call( + 'PreferencesController:getState', + ); + return preferencesState?.useTokenDetection ?? true; + } catch { + return true; + } + }; + readonly #customAssetGraduationMiddleware: CustomAssetGraduationMiddleware; readonly #rpcFallbackMiddleware: RpcFallbackMiddleware; @@ -964,7 +986,9 @@ export class AssetsController extends BaseController< getSelectedCurrency: (): SupportedCurrency => this.state.selectedCurrency, ...priceDataSourceConfig, }); - this.#detectionMiddleware = new DetectionMiddleware(); + this.#detectionMiddleware = new DetectionMiddleware({ + isTokenDetectionEnabled: this.#tokenDetectionEnabled, + }); this.#customAssetGraduationMiddleware = new CustomAssetGraduationMiddleware( { getSelectedAccountId: (): AccountId | undefined => { @@ -1169,6 +1193,20 @@ export class AssetsController extends BaseController< }, clientControllerSelectors.selectIsUiOpen, ); + // "Autodetect tokens" preference. Turning it on re-runs the pipeline so + // tokens skipped while it was off are detected without waiting for the + // next poll. Turning it off needs no refresh: already-tracked assets stay + // in state, and the next update strips new ones anyway. + this.messenger.subscribe( + 'PreferencesController:stateChange', + (useTokenDetection: boolean) => { + if (useTokenDetection) { + this.#refreshAssetsAfterTokenDetectionEnabled(); + } + }, + (state) => state.useTokenDetection, + ); + this.messenger.subscribe('KeyringController:unlock', () => { this.#keyringUnlocked = true; this.#updateActive(); @@ -3782,6 +3820,25 @@ export class AssetsController extends BaseController< }); } + /** + * Re-run the assets pipeline after the user turns "Autodetect tokens" back + * on, so tokens that were filtered out while it was off are detected, + * enriched, and priced right away. + */ + #refreshAssetsAfterTokenDetectionEnabled(): void { + const accounts = this.#getSelectedAccounts(); + if (accounts.length === 0) { + return; + } + + this.getAssets(accounts, { + forceUpdate: true, + dataTypes: ['balance', 'metadata', 'price'], + }).catch((error) => { + log('Failed to refresh assets after token detection enabled', { error }); + }); + } + /** * Refresh balances and fetch missing prices after a network is added. * @@ -3877,9 +3934,12 @@ export class AssetsController extends BaseController< // 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. + // Skipped when token detection is off — DetectionMiddleware strips + // every new token anyway, so the occurrence lookups would be wasted. const shouldFilterOccurrences = sourceId === 'AccountActivityDataSource' && - this.#isBasicFunctionality(); + this.#isBasicFunctionality() && + this.#tokenDetectionEnabled(); const enrichmentSources: AssetsDataSource[] = [ ...(shouldGraduateCustomAssets diff --git a/packages/assets-controller/src/index.ts b/packages/assets-controller/src/index.ts index fca7ca9647..98f4c8a3a1 100644 --- a/packages/assets-controller/src/index.ts +++ b/packages/assets-controller/src/index.ts @@ -172,6 +172,7 @@ export { } from './middlewares/index.js'; export type { CustomAssetGraduationMiddlewareOptions, + DetectionMiddlewareOptions, RpcFallbackMiddlewareOptions, } from './middlewares/index.js'; diff --git a/packages/assets-controller/src/middlewares/DetectionMiddleware.test.ts b/packages/assets-controller/src/middlewares/DetectionMiddleware.test.ts index e876e47f94..45fee81489 100644 --- a/packages/assets-controller/src/middlewares/DetectionMiddleware.test.ts +++ b/packages/assets-controller/src/middlewares/DetectionMiddleware.test.ts @@ -17,6 +17,13 @@ const MOCK_ASSET_1 = const MOCK_ASSET_2 = 'eip155:1/erc20:0xdac17f958d2ee523a2206206994597c13d831ec7' as Caip19AssetId; const MOCK_NATIVE_ASSET = 'eip155:1/slip44:60' as Caip19AssetId; +const MOCK_SOL_NATIVE_ASSET = + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501' as Caip19AssetId; +const MOCK_SPL_ASSET = + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v' as Caip19AssetId; +// Mainnet pooled-staking contract — exempt from detection gating. +const MOCK_STAKING_ASSET = + 'eip155:1/erc20:0x4fef9d741011476750a243ac70b9789a63dd47df' as Caip19AssetId; function createMockAccount( overrides?: Partial, @@ -435,6 +442,202 @@ describe('DetectionMiddleware', () => { expect(next).toHaveBeenCalledWith(context); }); + describe('when token detection is disabled', () => { + const setupDisabled = (): DetectionMiddleware => + new DetectionMiddleware({ isTokenDetectionEnabled: () => false }); + + it('does not detect new tokens and strips them from the response (erc20 and SPL), keeping natives', async () => { + const middleware = setupDisabled(); + const context = createMiddlewareContext({ + response: { + assetsBalance: { + [MOCK_ACCOUNT_ID]: { + [MOCK_ASSET_1]: { amount: '1000' }, + [MOCK_SPL_ASSET]: { amount: '5' }, + [MOCK_NATIVE_ASSET]: { amount: '2' }, + [MOCK_SOL_NATIVE_ASSET]: { amount: '3' }, + }, + }, + assetsInfo: { + [MOCK_ASSET_1]: { + type: 'erc20', + name: 'Stub Token', + symbol: 'STUB', + decimals: 18, + }, + }, + }, + }); + const next = jest.fn().mockImplementation((ctx) => Promise.resolve(ctx)); + + await middleware.assetsMiddleware(context, next); + + // Tokens are gone from balances and stub metadata; natives survive and + // are still detected so their metadata/prices get fetched. + expect(context.response.assetsBalance?.[MOCK_ACCOUNT_ID]).toStrictEqual({ + [MOCK_NATIVE_ASSET]: { amount: '2' }, + [MOCK_SOL_NATIVE_ASSET]: { amount: '3' }, + }); + expect(context.response.assetsInfo?.[MOCK_ASSET_1]).toBeUndefined(); + expect(context.response.detectedAssets).toStrictEqual({ + [MOCK_ACCOUNT_ID]: [MOCK_NATIVE_ASSET, MOCK_SOL_NATIVE_ASSET], + }); + expect(next).toHaveBeenCalledWith(context); + }); + + it('keeps balance updates for tokens already tracked in state balances', async () => { + const middleware = setupDisabled(); + const context = createMiddlewareContext({ + response: { + assetsBalance: { + [MOCK_ACCOUNT_ID]: { + [MOCK_ASSET_1]: { amount: '1000' }, + }, + }, + }, + getAssetsState: jest.fn().mockReturnValue({ + assetsInfo: {}, + assetsBalance: { + [MOCK_ACCOUNT_ID]: { [MOCK_ASSET_1]: { amount: '5' } }, + }, + customAssets: {}, + assetsPrice: {}, + }), + }); + const next = jest.fn().mockImplementation((ctx) => Promise.resolve(ctx)); + + await middleware.assetsMiddleware(context, next); + + expect( + context.response.assetsBalance?.[MOCK_ACCOUNT_ID]?.[MOCK_ASSET_1], + ).toStrictEqual({ amount: '1000' }); + expect(context.response.detectedAssets).toBeUndefined(); + }); + + it('keeps balance updates for tokens known through state metadata', async () => { + const middleware = setupDisabled(); + const context = createMiddlewareContext( + { + response: { + assetsBalance: { + [MOCK_ACCOUNT_ID]: { + [MOCK_ASSET_1]: { amount: '1000' }, + }, + }, + }, + }, + [MOCK_ASSET_1], + ); + const next = jest.fn().mockImplementation((ctx) => Promise.resolve(ctx)); + + await middleware.assetsMiddleware(context, next); + + expect( + context.response.assetsBalance?.[MOCK_ACCOUNT_ID]?.[MOCK_ASSET_1], + ).toStrictEqual({ amount: '1000' }); + }); + + it('keeps and still detects custom assets (user-imported)', async () => { + const middleware = setupDisabled(); + const context = createMiddlewareContext({ + response: { + assetsBalance: { + [MOCK_ACCOUNT_ID]: { + [MOCK_ASSET_1]: { amount: '1000' }, + [MOCK_ASSET_2]: { amount: '2000' }, + }, + }, + }, + getAssetsState: jest.fn().mockReturnValue({ + assetsInfo: {}, + assetsBalance: {}, + customAssets: { [MOCK_ACCOUNT_ID]: [MOCK_ASSET_1] }, + assetsPrice: {}, + }), + }); + const next = jest.fn().mockImplementation((ctx) => Promise.resolve(ctx)); + + await middleware.assetsMiddleware(context, next); + + // Custom asset survives and is detected; the other token is stripped. + expect(context.response.assetsBalance?.[MOCK_ACCOUNT_ID]).toStrictEqual({ + [MOCK_ASSET_1]: { amount: '1000' }, + }); + expect(context.response.detectedAssets).toStrictEqual({ + [MOCK_ACCOUNT_ID]: [MOCK_ASSET_1], + }); + }); + + it('exempts custom assets stored in a different address case', async () => { + const middleware = setupDisabled(); + const checksummedAsset = + 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Caip19AssetId; + const context = createMiddlewareContext({ + response: { + assetsBalance: { + [MOCK_ACCOUNT_ID]: { + [MOCK_ASSET_1]: { amount: '1000' }, + }, + }, + }, + getAssetsState: jest.fn().mockReturnValue({ + assetsInfo: {}, + assetsBalance: {}, + customAssets: { [MOCK_ACCOUNT_ID]: [checksummedAsset] }, + assetsPrice: {}, + }), + }); + const next = jest.fn().mockImplementation((ctx) => Promise.resolve(ctx)); + + await middleware.assetsMiddleware(context, next); + + expect( + context.response.assetsBalance?.[MOCK_ACCOUNT_ID]?.[MOCK_ASSET_1], + ).toStrictEqual({ amount: '1000' }); + }); + + it('keeps staking contract balances', async () => { + const middleware = setupDisabled(); + const context = createMiddlewareContext({ + response: { + assetsBalance: { + [MOCK_ACCOUNT_ID]: { + [MOCK_STAKING_ASSET]: { amount: '1.5' }, + }, + }, + }, + }); + const next = jest.fn().mockImplementation((ctx) => Promise.resolve(ctx)); + + await middleware.assetsMiddleware(context, next); + + expect( + context.response.assetsBalance?.[MOCK_ACCOUNT_ID]?.[MOCK_STAKING_ASSET], + ).toStrictEqual({ amount: '1.5' }); + }); + + it('does not queue prices for stripped tokens but still queues natives', async () => { + const middleware = setupDisabled(); + const context = createMiddlewareContext({ + response: { + assetsBalance: { + [MOCK_ACCOUNT_ID]: { + [MOCK_ASSET_1]: { amount: '1000' }, + [MOCK_NATIVE_ASSET]: { amount: '2' }, + }, + }, + }, + }); + const next = jest.fn().mockImplementation((ctx) => Promise.resolve(ctx)); + + await middleware.assetsMiddleware(context, next); + + expect(context.request.assetsForPriceUpdate).toStrictEqual([ + normalizeAssetId(MOCK_NATIVE_ASSET), + ]); + }); + }); + it('retrieves middleware from instance', async () => { const { middleware } = setupController(); const middlewareFn = middleware.assetsMiddleware; diff --git a/packages/assets-controller/src/middlewares/DetectionMiddleware.ts b/packages/assets-controller/src/middlewares/DetectionMiddleware.ts index 805c59143e..e66bc8a9c3 100644 --- a/packages/assets-controller/src/middlewares/DetectionMiddleware.ts +++ b/packages/assets-controller/src/middlewares/DetectionMiddleware.ts @@ -1,3 +1,7 @@ +import { parseCaipAssetType } from '@metamask/utils'; + +import { isStakingContractAssetId } from '../data-sources/evm-rpc-services/index.js'; +import { CaipAssetNamespace } from '../data-sources/TokenDataSource.js'; import { projectLogger, createModuleLogger } from '../logger.js'; import { forDataTypes } from '../types.js'; import type { AccountId, Caip19AssetId, Middleware } from '../types.js'; @@ -9,8 +13,28 @@ import { normalizeAssetId } from '../utils/index.js'; const CONTROLLER_NAME = 'DetectionMiddleware'; -// Logger for debugging -createModuleLogger(projectLogger, CONTROLLER_NAME); +const log = createModuleLogger(projectLogger, CONTROLLER_NAME); + +/** + * Whether an asset is subject to the token-detection toggle: fungible tokens + * (EVM `erc20`, non-EVM `token` — SPL, TRC, etc.). Natives (`slip44`), + * staking-contract assets, and anything unparseable are never gated. + * + * @param assetId - The CAIP-19 asset ID to classify. + * @returns `true` when the asset should be blocked while detection is off. + */ +function isDetectionGatedAsset(assetId: string): boolean { + try { + const { assetNamespace } = parseCaipAssetType(assetId as Caip19AssetId); + return ( + (assetNamespace === (CaipAssetNamespace.Erc20 as string) || + assetNamespace === (CaipAssetNamespace.Token as string)) && + !isStakingContractAssetId(assetId) + ); + } catch { + return false; + } +} // ============================================================================ // DETECTION MIDDLEWARE @@ -29,15 +53,38 @@ createModuleLogger(projectLogger, CONTROLLER_NAME); * - Each account's custom assets from state are always included because they * may have no balance yet and are explicitly managed by the user. * + * When token detection is disabled (`isTokenDetectionEnabled` returns false), + * new-to-state fungible tokens (`erc20` / `token` namespaces) are neither + * detected nor persisted: their balances and stub metadata are stripped from + * the response. Natives, staking-contract assets, custom (user-imported) + * assets, and assets already tracked in state are unaffected. + * * Usage: * ```typescript * const detectionMiddleware = new DetectionMiddleware(); * const middleware = detectionMiddleware.assetsMiddleware; * ``` */ + +/** Constructor options for {@link DetectionMiddleware}. */ +export type DetectionMiddlewareOptions = { + /** + * Whether automatic token detection is enabled (e.g. the user's + * "Autodetect tokens" preference). Defaults to `() => true`. + */ + isTokenDetectionEnabled?: () => boolean; +}; + export class DetectionMiddleware { readonly name = CONTROLLER_NAME; + readonly #isTokenDetectionEnabled: () => boolean; + + constructor(options: DetectionMiddlewareOptions = {}) { + this.#isTokenDetectionEnabled = + options.isTokenDetectionEnabled ?? ((): boolean => true); + } + getName(): string { return this.name; } @@ -71,6 +118,21 @@ export class DetectionMiddleware { const detectedAssets: Record = {}; + const detectionEnabled = this.#isTokenDetectionEnabled(); + // Lower-cased asset IDs stripped from the response because token + // detection is off; used to purge their stub metadata afterwards. + const strippedAssetIds = new Set(); + // Lower-cased state metadata keys, built lazily (only when detection is + // off) so the destructive strip below cannot misfire on a known holding + // reported by a source in a different address case. + let knownMetadataLower: Set | null = null; + const getKnownMetadataLower = (): Set => { + knownMetadataLower ??= new Set( + Object.keys(stateAssetsInfo).map((id) => id.toLowerCase()), + ); + return knownMetadataLower; + }; + // 1. From balance response: only include assets that are genuinely new — // not already present in state.assetsBalance or state.assetsInfo. if (response.assetsBalance) { @@ -80,6 +142,15 @@ export class DetectionMiddleware { const detected: Caip19AssetId[] = []; const stateAccountBalances = stateAssetsBalance[accountId] ?? {}; + const customForAccount = stateCustomAssets?.[accountId] ?? []; + const customLowerIds = detectionEnabled + ? null + : new Set(customForAccount.map((id) => id.toLowerCase())); + const knownBalanceLower = detectionEnabled + ? null + : new Set( + Object.keys(stateAccountBalances).map((id) => id.toLowerCase()), + ); for (const assetId of Object.keys( accountBalances as Record, @@ -92,12 +163,32 @@ export class DetectionMiddleware { ) { continue; } + + // Token detection off: new-to-state fungible tokens (erc20 / SPL + // and similar `token` namespaces) are neither detected nor + // persisted. Natives, staking contracts, custom assets, and + // holdings already known to state (any address case) still flow. + if ( + !detectionEnabled && + isDetectionGatedAsset(assetId) && + !customLowerIds?.has(assetId.toLowerCase()) + ) { + const lowerId = assetId.toLowerCase(); + if ( + !knownBalanceLower?.has(lowerId) && + !getKnownMetadataLower().has(lowerId) + ) { + delete (accountBalances as Record)[assetId]; + strippedAssetIds.add(lowerId); + } + continue; + } + detected.push(caipAssetId); } // Merge custom assets for this account, applying the same filter: // skip if already in state balance or already has metadata. - const customForAccount = stateCustomAssets?.[accountId] ?? []; for (const assetId of customForAccount) { if (detected.includes(assetId)) { continue; @@ -117,6 +208,23 @@ export class DetectionMiddleware { } } + // Drop stub metadata (e.g. websocket-seeded name/symbol) of stripped + // tokens so it never persists to state — a persisted stub would make + // the token look "known" on the next update and let it bypass the + // detection gate. + if (strippedAssetIds.size > 0) { + if (response.assetsInfo) { + for (const assetId of Object.keys(response.assetsInfo)) { + if (strippedAssetIds.has(assetId.toLowerCase())) { + delete response.assetsInfo[assetId as Caip19AssetId]; + } + } + } + log('Token detection disabled - stripped new tokens from response', { + assetIds: [...strippedAssetIds], + }); + } + // 2. Accounts in request that weren't in balance response: include their // custom assets that are not yet in state. for (const { account } of request.accountsWithSupportedChains) { diff --git a/packages/assets-controller/src/middlewares/index.ts b/packages/assets-controller/src/middlewares/index.ts index d6796382aa..aa509e625f 100644 --- a/packages/assets-controller/src/middlewares/index.ts +++ b/packages/assets-controller/src/middlewares/index.ts @@ -1,6 +1,7 @@ export { CustomAssetGraduationMiddleware } from './CustomAssetGraduationMiddleware.js'; export type { CustomAssetGraduationMiddlewareOptions } from './CustomAssetGraduationMiddleware.js'; export { DetectionMiddleware } from './DetectionMiddleware.js'; +export type { DetectionMiddlewareOptions } from './DetectionMiddleware.js'; export { RpcFallbackMiddleware } from './RpcFallbackMiddleware.js'; export type { RpcFallbackMiddlewareOptions } from './RpcFallbackMiddleware.js'; export {