diff --git a/packages/assets-controllers/CHANGELOG.md b/packages/assets-controllers/CHANGELOG.md index 22e9bc8433e..1d683a92c09 100644 --- a/packages/assets-controllers/CHANGELOG.md +++ b/packages/assets-controllers/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- 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. - Add Robinhood Chain (`4663`/`0x1237`) to `SUPPORTED_NETWORKS_ACCOUNTS_API_V4` so balances and token detection use the Accounts API instead of RPC-only ([#9547](https://github.com/MetaMask/core/pull/9547)) - Add Robinhood Chain (`4663`/`0x1237`) BalanceFetcher support for legacy single-call token detection ([#9473](https://github.com/MetaMask/core/pull/9473)) - Add `Robinhood` in `SupportedTokenDetectionNetworks` 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..fb62fc3308e 100644 --- a/packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController.ts +++ b/packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController.ts @@ -212,15 +212,19 @@ export class MultichainAssetsController extends StaticIntervalPollingController< readonly #controllerOperationMutex = new Mutex(); + readonly #isDeprecated: () => boolean; + 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 +237,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 +265,30 @@ 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. + */ + #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,14 +335,22 @@ export class MultichainAssetsController extends StaticIntervalPollingController< async #handleAccountAssetListUpdatedEvent( event: AccountAssetListUpdatedEventPayload, ) { - return this.#withControllerLock(async () => + if (this.#isDeprecated()) { + this.#enforceDisabledState(); + return; + } + await this.#withControllerLock(async () => this.#handleAccountAssetListUpdated(event), ); } // eslint-disable-next-line @typescript-eslint/explicit-function-return-type async #handleOnAccountAddedEvent(account: InternalAccount) { - return this.#withControllerLock(async () => + if (this.#isDeprecated()) { + this.#enforceDisabledState(); + return; + } + await this.#withControllerLock(async () => this.#handleOnAccountAdded(account), ); } @@ -334,6 +372,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 +407,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 +630,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];