From 8ca6b82e42fdd1077d35205651b67c27469abb2c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 30 Jun 2026 08:56:28 +0000 Subject: [PATCH 1/5] feat(assets-controllers): add isDeprecated to MultichainAssetsController Adds the optional `isDeprecated` constructor callback to MultichainAssetsController, following the same pattern introduced in #9044 for MultichainAssetsRatesController and MultichainBalancesController and #8945 for TokenListController. When isDeprecated() returns true, no Snap requests are issued and accountsAssets, assetsMetadata, and allIgnoredAssets are reset to {} at construction and at every entry point (addAssets, ignoreAssets, _executePoll, AccountsController:accountAdded, AccountsController:accountRemoved, and AccountsController:accountAssetListUpdated). The callback is re-evaluated on each entry point so deprecation can be toggled at runtime. Co-authored-by: Prithpal Sooriya --- packages/assets-controllers/CHANGELOG.md | 6 + .../MultichainAssetsController.test.ts | 196 ++++++++++++++++++ .../MultichainAssetsController.ts | 74 ++++++- 3 files changed, 275 insertions(+), 1 deletion(-) diff --git a/packages/assets-controllers/CHANGELOG.md b/packages/assets-controllers/CHANGELOG.md index ad2e3bb449d..0269eaa19b9 100644 --- a/packages/assets-controllers/CHANGELOG.md +++ b/packages/assets-controllers/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `isDeprecated` option to `MultichainAssetsController` constructor ([#9319](https://github.com/MetaMask/core/pull/9319)) + - When `isDeprecated()` returns `true`, no Snap requests are issued and `accountsAssets`, `assetsMetadata`, and `allIgnoredAssets` are reset to `{}` at construction and at every entry point (`addAssets`, `ignoreAssets`, `_executePoll`, `AccountsController:accountAdded`, `AccountsController:accountRemoved`, and `AccountsController:accountAssetListUpdated`), so no stale asset data remains in state. + - The function is re-evaluated on each entry point so it can be toggled at runtime without reconstructing the controller. + ### Changed - Bump `@metamask/keyring-api` from `^23.1.0` to `^23.3.0` ([#9249](https://github.com/MetaMask/core/pull/9249)) diff --git a/packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController.test.ts b/packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController.test.ts index f1bcd86cece..9bbb4ceabe6 100644 --- a/packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController.test.ts +++ b/packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController.test.ts @@ -263,9 +263,11 @@ const setupController = ({ mocks, /** `0` disables periodic Blockaid re-scan (default for tests). */ blockaidTokenRescanInterval = 0, + isDeprecated, }: { state?: MultichainAssetsControllerState; blockaidTokenRescanInterval?: number; + isDeprecated?: () => boolean; mocks?: { listMultichainAccounts?: InternalAccount[]; handleRequestReturnValue?: CaipAssetTypeOrId[]; @@ -353,6 +355,7 @@ const setupController = ({ messenger: multichainAssetsControllerMessenger, state, blockaidTokenRescanInterval, + ...(isDeprecated && { isDeprecated }), }); return { @@ -1982,6 +1985,199 @@ describe('MultichainAssetsController', () => { }); }); + describe('isDeprecated', () => { + const deprecatedAccountId = mockSolanaAccount.id; + + const initialState: MultichainAssetsControllerState = { + accountsAssets: { + [deprecatedAccountId]: [ + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501', + ], + }, + assetsMetadata: { + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501': { + name: 'Solana', + symbol: 'SOL', + fungible: true, + iconUrl: 'url1', + units: [{ name: 'Solana', symbol: 'SOL', decimals: 9 }], + }, + }, + allIgnoredAssets: { + [deprecatedAccountId]: [ + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:Spam', + ], + }, + }; + + const emptyState: MultichainAssetsControllerState = { + accountsAssets: {}, + assetsMetadata: {}, + allIgnoredAssets: {}, + }; + + it('clears all persisted state at construction when isDeprecated() returns true', () => { + const { controller } = setupController({ + state: initialState, + isDeprecated: () => true, + }); + + expect(controller.state).toStrictEqual(emptyState); + }); + + it('preserves persisted state at construction when isDeprecated() returns false', () => { + const { controller } = setupController({ + state: initialState, + isDeprecated: () => false, + }); + + expect(controller.state).toStrictEqual(initialState); + }); + + it('does not throw at construction when isDeprecated() is true and state is already empty', () => { + const { controller } = setupController({ + isDeprecated: () => true, + }); + + expect(controller.state).toStrictEqual(emptyState); + }); + + it('does not issue Snap requests at construction when isDeprecated() returns true', () => { + const { mockSnapHandleRequest } = setupController({ + state: initialState, + blockaidTokenRescanInterval: 60_000, + isDeprecated: () => true, + }); + + expect(mockSnapHandleRequest).not.toHaveBeenCalled(); + }); + + it('does not add assets and clears stale state when isDeprecated toggles to true at runtime via addAssets', async () => { + let deprecated = false; + const { controller, mockSnapHandleRequest } = setupController({ + state: initialState, + isDeprecated: () => deprecated, + }); + + expect(controller.state).toStrictEqual(initialState); + + deprecated = true; + mockSnapHandleRequest.mockClear(); + + const result = await controller.addAssets( + ['solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:NewToken'], + deprecatedAccountId, + ); + + expect(result).toStrictEqual([]); + expect(controller.state).toStrictEqual(emptyState); + expect(mockSnapHandleRequest).not.toHaveBeenCalled(); + }); + + it('does not ignore assets and clears stale state when isDeprecated toggles to true at runtime via ignoreAssets', () => { + let deprecated = false; + const { controller } = setupController({ + state: initialState, + isDeprecated: () => deprecated, + }); + + expect(controller.state).toStrictEqual(initialState); + + deprecated = true; + + controller.ignoreAssets( + ['solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501'], + deprecatedAccountId, + ); + + expect(controller.state).toStrictEqual(emptyState); + }); + + it('clears stale state and skips Snap requests on "AccountsController:accountAdded" when isDeprecated toggles to true at runtime', async () => { + let deprecated = false; + const { controller, messenger, mockSnapHandleRequest } = setupController({ + state: initialState, + isDeprecated: () => deprecated, + }); + + expect(controller.state).toStrictEqual(initialState); + + deprecated = true; + mockSnapHandleRequest.mockClear(); + + messenger.publish( + 'AccountsController:accountAdded', + mockSolanaAccount as unknown as InternalAccount, + ); + + await jestAdvanceTime({ duration: 1 }); + + expect(controller.state).toStrictEqual(emptyState); + expect(mockSnapHandleRequest).not.toHaveBeenCalled(); + }); + + it('clears stale state on "AccountsController:accountRemoved" when isDeprecated toggles to true at runtime', async () => { + let deprecated = false; + const { controller, messenger } = setupController({ + state: initialState, + isDeprecated: () => deprecated, + }); + + expect(controller.state).toStrictEqual(initialState); + + deprecated = true; + + messenger.publish( + 'AccountsController:accountRemoved', + deprecatedAccountId, + ); + + await jestAdvanceTime({ duration: 1 }); + + expect(controller.state).toStrictEqual(emptyState); + }); + + it('clears stale state and skips Snap requests on "AccountsController:accountAssetListUpdated" when isDeprecated toggles to true at runtime', async () => { + let deprecated = false; + const { controller, messenger, mockSnapHandleRequest } = setupController({ + state: initialState, + isDeprecated: () => deprecated, + }); + + expect(controller.state).toStrictEqual(initialState); + + deprecated = true; + mockSnapHandleRequest.mockClear(); + + messenger.publish('AccountsController:accountAssetListUpdated', { + assets: { + [deprecatedAccountId]: { + added: ['solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:NewToken'], + removed: [], + }, + }, + }); + + await jestAdvanceTime({ duration: 1 }); + + expect(controller.state).toStrictEqual(emptyState); + expect(mockSnapHandleRequest).not.toHaveBeenCalled(); + }); + + it('does not run the periodic Blockaid rescan when isDeprecated() returns true', async () => { + const { controller, mockBulkScanTokens } = setupController({ + blockaidTokenRescanInterval: 60_000, + state: initialState, + isDeprecated: () => true, + }); + + await jestAdvanceTime({ duration: 1 }); + + expect(mockBulkScanTokens).not.toHaveBeenCalled(); + expect(controller.state).toStrictEqual(emptyState); + }); + }); + describe('metadata', () => { it('includes expected state in debug snapshots', () => { const { controller } = setupController(); diff --git a/packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController.ts b/packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController.ts index 3bd5ee7e12f..c2079be6b16 100644 --- a/packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController.ts +++ b/packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController.ts @@ -212,15 +212,35 @@ export class MultichainAssetsController extends StaticIntervalPollingController< readonly #controllerOperationMutex = new Mutex(); + readonly #isDeprecated: () => boolean; + + /** + * Creates an instance of MultichainAssetsController. + * + * @param options - Constructor options. + * @param options.messenger - A reference to the messenger. + * @param options.state - Initial state to set on this controller. + * @param options.blockaidTokenRescanInterval - Blockaid re-scan interval (ms); + * default daily. `0` disables. + * @param options.isDeprecated - Optional function that returns true to completely + * disable this controller (no Snap requests, no state updates). When it returns + * `true`, `accountsAssets`, `assetsMetadata`, and `allIgnoredAssets` are reset to + * `{}` at construction and at every entry point, so no stale asset data remains + * in state. The function is evaluated dynamically on each entry point so it can + * be toggled at runtime. Intended for use when a higher-level controller + * (e.g. AssetsController) supersedes this one. + */ constructor({ messenger, state = {}, blockaidTokenRescanInterval = DEFAULT_BLOCKAID_TOKEN_RESCAN_INTERVAL_MS, + isDeprecated = (): boolean => false, }: { messenger: MultichainAssetsControllerMessenger; state?: Partial; /** Blockaid re-scan interval (ms); default daily. `0` disables. */ blockaidTokenRescanInterval?: number; + isDeprecated?: () => boolean; }) { super({ messenger, @@ -233,8 +253,11 @@ export class MultichainAssetsController extends StaticIntervalPollingController< }); this.#snaps = {}; + this.#isDeprecated = isDeprecated; - if (blockaidTokenRescanInterval > 0) { + if (this.#isDeprecated()) { + this.#enforceDisabledState(); + } else if (blockaidTokenRescanInterval > 0) { this.setIntervalLength(blockaidTokenRescanInterval); this.startPolling(null); } @@ -258,7 +281,35 @@ export class MultichainAssetsController extends StaticIntervalPollingController< messenger.registerMethodActionHandlers(this, MESSENGER_EXPOSED_METHODS); } + /** + * Clears all persisted `accountsAssets`, `assetsMetadata`, and + * `allIgnoredAssets` so that no stale asset data remains in state. + * + * Called from every entry point when `isDeprecated()` is true so that a + * runtime toggle propagates to state immediately, even if the controller was + * originally constructed while it was enabled. The update is skipped when all + * three maps are already empty to avoid emitting redundant state changes. + */ + #enforceDisabledState(): void { + if ( + Object.keys(this.state.accountsAssets).length === 0 && + Object.keys(this.state.assetsMetadata).length === 0 && + Object.keys(this.state.allIgnoredAssets).length === 0 + ) { + return; + } + this.update((state) => { + state.accountsAssets = {}; + state.assetsMetadata = {}; + state.allIgnoredAssets = {}; + }); + } + async _executePoll(_input: null): Promise { + if (this.#isDeprecated()) { + this.#enforceDisabledState(); + return; + } await this.#withControllerLock(async () => { const assetsByAccount: Record< string, @@ -305,6 +356,10 @@ export class MultichainAssetsController extends StaticIntervalPollingController< async #handleAccountAssetListUpdatedEvent( event: AccountAssetListUpdatedEventPayload, ) { + if (this.#isDeprecated()) { + this.#enforceDisabledState(); + return undefined; + } return this.#withControllerLock(async () => this.#handleAccountAssetListUpdated(event), ); @@ -312,6 +367,10 @@ export class MultichainAssetsController extends StaticIntervalPollingController< // eslint-disable-next-line @typescript-eslint/explicit-function-return-type async #handleOnAccountAddedEvent(account: InternalAccount) { + if (this.#isDeprecated()) { + this.#enforceDisabledState(); + return undefined; + } return this.#withControllerLock(async () => this.#handleOnAccountAdded(account), ); @@ -334,6 +393,10 @@ export class MultichainAssetsController extends StaticIntervalPollingController< * @param accountId - The account ID to ignore assets for. */ ignoreAssets(assetsToIgnore: CaipAssetType[], accountId: string): void { + if (this.#isDeprecated()) { + this.#enforceDisabledState(); + return; + } this.update((state) => { if (state.accountsAssets[accountId]) { state.accountsAssets[accountId] = state.accountsAssets[ @@ -365,6 +428,11 @@ export class MultichainAssetsController extends StaticIntervalPollingController< assetIds: CaipAssetType[], accountId: string, ): Promise { + if (this.#isDeprecated()) { + this.#enforceDisabledState(); + return []; + } + if (assetIds.length === 0) { return this.state.accountsAssets[accountId] || []; } @@ -583,6 +651,10 @@ export class MultichainAssetsController extends StaticIntervalPollingController< * @param accountId - The new account id being removed. */ async #handleOnAccountRemovedEvent(accountId: string): Promise { + if (this.#isDeprecated()) { + this.#enforceDisabledState(); + return; + } this.update((state) => { if (state.accountsAssets[accountId]) { delete state.accountsAssets[accountId]; From 9cecaa8e1414d0cdf5d802899d31496ff24d07f3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 30 Jun 2026 08:58:36 +0000 Subject: [PATCH 2/5] docs(assets-controllers): use correct PR link in changelog entry Co-authored-by: Prithpal Sooriya --- packages/assets-controllers/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/assets-controllers/CHANGELOG.md b/packages/assets-controllers/CHANGELOG.md index 0269eaa19b9..75e81426724 100644 --- a/packages/assets-controllers/CHANGELOG.md +++ b/packages/assets-controllers/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add `isDeprecated` option to `MultichainAssetsController` constructor ([#9319](https://github.com/MetaMask/core/pull/9319)) +- Add `isDeprecated` option to `MultichainAssetsController` constructor ([#9310](https://github.com/MetaMask/core/pull/9310)) - When `isDeprecated()` returns `true`, no Snap requests are issued and `accountsAssets`, `assetsMetadata`, and `allIgnoredAssets` are reset to `{}` at construction and at every entry point (`addAssets`, `ignoreAssets`, `_executePoll`, `AccountsController:accountAdded`, `AccountsController:accountRemoved`, and `AccountsController:accountAssetListUpdated`), so no stale asset data remains in state. - The function is re-evaluated on each entry point so it can be toggled at runtime without reconstructing the controller. From c2314abf9b1fab2b809770893346cee14011644c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 20 Jul 2026 20:59:34 +0000 Subject: [PATCH 3/5] docs(assets-controllers): remove constructor JSDoc to reduce diff Co-authored-by: Prithpal Sooriya --- .../MultichainAssetsController.ts | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController.ts b/packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController.ts index c2079be6b16..811c090f02f 100644 --- a/packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController.ts +++ b/packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController.ts @@ -214,22 +214,6 @@ export class MultichainAssetsController extends StaticIntervalPollingController< readonly #isDeprecated: () => boolean; - /** - * Creates an instance of MultichainAssetsController. - * - * @param options - Constructor options. - * @param options.messenger - A reference to the messenger. - * @param options.state - Initial state to set on this controller. - * @param options.blockaidTokenRescanInterval - Blockaid re-scan interval (ms); - * default daily. `0` disables. - * @param options.isDeprecated - Optional function that returns true to completely - * disable this controller (no Snap requests, no state updates). When it returns - * `true`, `accountsAssets`, `assetsMetadata`, and `allIgnoredAssets` are reset to - * `{}` at construction and at every entry point, so no stale asset data remains - * in state. The function is evaluated dynamically on each entry point so it can - * be toggled at runtime. Intended for use when a higher-level controller - * (e.g. AssetsController) supersedes this one. - */ constructor({ messenger, state = {}, From f6131d0635ec54dbf098b0065470140437dc9973 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 20 Jul 2026 21:00:18 +0000 Subject: [PATCH 4/5] docs(assets-controllers): shorten enforceDisabledState comment Co-authored-by: Prithpal Sooriya --- .../MultichainAssetsController/MultichainAssetsController.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController.ts b/packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController.ts index 811c090f02f..ae472a49796 100644 --- a/packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController.ts +++ b/packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController.ts @@ -268,11 +268,6 @@ export class MultichainAssetsController extends StaticIntervalPollingController< /** * Clears all persisted `accountsAssets`, `assetsMetadata`, and * `allIgnoredAssets` so that no stale asset data remains in state. - * - * Called from every entry point when `isDeprecated()` is true so that a - * runtime toggle propagates to state immediately, even if the controller was - * originally constructed while it was enabled. The update is skipped when all - * three maps are already empty to avoid emitting redundant state changes. */ #enforceDisabledState(): void { if ( From b6b12ee60e0b825319ce49c1a70df708ea0f5742 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 20 Jul 2026 22:25:15 +0000 Subject: [PATCH 5/5] refactor(assets-controllers): await lock in deprecated event handlers to use bare return Co-authored-by: Prithpal Sooriya --- .../MultichainAssetsController.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController.ts b/packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController.ts index ae472a49796..fb62fc3308e 100644 --- a/packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController.ts +++ b/packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController.ts @@ -337,9 +337,9 @@ export class MultichainAssetsController extends StaticIntervalPollingController< ) { if (this.#isDeprecated()) { this.#enforceDisabledState(); - return undefined; + return; } - return this.#withControllerLock(async () => + await this.#withControllerLock(async () => this.#handleAccountAssetListUpdated(event), ); } @@ -348,9 +348,9 @@ export class MultichainAssetsController extends StaticIntervalPollingController< async #handleOnAccountAddedEvent(account: InternalAccount) { if (this.#isDeprecated()) { this.#enforceDisabledState(); - return undefined; + return; } - return this.#withControllerLock(async () => + await this.#withControllerLock(async () => this.#handleOnAccountAdded(account), ); }