Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion eslint-suppressions.json
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@
},
"packages/assets-controller/src/AssetsController.ts": {
"no-restricted-syntax": {
"count": 3
"count": 4
}
},
"packages/assets-controller/src/__fixtures__/MockAssetControllerMessenger.ts": {
Expand Down
7 changes: 7 additions & 0 deletions packages/assets-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
130 changes: 130 additions & 0 deletions packages/assets-controller/src/AssetsController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down Expand Up @@ -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();
Expand Down
68 changes: 64 additions & 4 deletions packages/assets-controller/src/AssetsController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 => {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/assets-controller/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ export {
} from './middlewares/index.js';
export type {
CustomAssetGraduationMiddlewareOptions,
DetectionMiddlewareOptions,
RpcFallbackMiddlewareOptions,
} from './middlewares/index.js';

Expand Down
Loading
Loading