Repository: MetaMask/core
Pull request #5726: chore: reduce tokenBalances state updates
Author: login: sahar-fehri
Labels: name: area-performance
description: Issues relating to slowness of app, cpu usage, and/or blank screens.
Created: 2025-04-29T12:05:13Z | Merged: 2025-05-23T13:57:14Z

## Explanation

When a user removes an account; tokenBalances controller would still fetch balances for the tokens and update state.
This was happening because tokensController does not remove tokens from state when a user removes the account.

This PR cleans up tokens from tokensController state once a user removes an account.
It also cleans up the balances from state when the user removes the account.

This PR also adds a check to see if token balances has changed after fetching them; if none of the balances changed; no need to update state.

## References

<!--
Are there any issues that this pull request is tied to?
Are there other links that reviewers should consult to understand these changes better?
Are there client or consumer pull requests to adopt any breaking changes?

For example:

* Fixes #12345
* Related to #67890
-->

## Changelog

<!--
THIS SECTION IS NO LONGER NEEDED.

The process for updating changelogs has changed. Please consult the "Updating changelogs" section of the Contributing doc for more.
-->

## Checklist

- [ ] I've updated the test suite for new or updated code as appropriate
- [ ] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate
- [ ] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/contributing.md#updating-changelogs), highlighting breaking changes as necessary
- [ ] I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes



Diff:
diff --git a/eslint-warning-thresholds.json b/eslint-warning-thresholds.json
index 18526b5dd5b..1ccfeb8c6ef 100644
--- a/eslint-warning-thresholds.json
+++ b/eslint-warning-thresholds.json
@@ -73,9 +73,6 @@
   "packages/assets-controllers/src/Standards/NftStandards/ERC721/ERC721Standard.ts": {
     "prettier/prettier": 1
   },
-  "packages/assets-controllers/src/TokenBalancesController.test.ts": {
-    "import-x/order": 1
-  },
   "packages/assets-controllers/src/TokenBalancesController.ts": {
     "@typescript-eslint/prefer-readonly": 4,
     "jsdoc/check-tag-names": 4,
diff --git a/packages/assets-controllers/CHANGELOG.md b/packages/assets-controllers/CHANGELOG.md
index f4e784a6d0e..3b201f85b57 100644
--- a/packages/assets-controllers/CHANGELOG.md
+++ b/packages/assets-controllers/CHANGELOG.md
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ## [Unreleased]
 
+### Added
+
+- **BREAKING:** Add event listener for `AccountsController:accountRemoved` on `TokenBalancesController` to remove token balances for the removed account ([#5726](https://github.com/MetaMask/core/pull/5726))
+
+- **BREAKING:** Add event listener for `AccountsController:accountRemoved` on `TokensController` to remove tokens for the removed account ([#5726](https://github.com/MetaMask/core/pull/5726))
+
+- **BREAKING:** Add `listAccounts` action to `TokensController` ([#5726](https://github.com/MetaMask/core/pull/5726))
+
+- **BREAKING:** Add `listAccounts` action to `TokenBalancesController` ([#5726](https://github.com/MetaMask/core/pull/5726))
+
+### Changed
+
+- TokenBalancesController will now check if balances has changed before updating the state ([#5726](https://github.com/MetaMask/core/pull/5726))
+
 ## [63.1.0]
 
 ### Changed
diff --git a/packages/assets-controllers/src/TokenBalancesController.test.ts b/packages/assets-controllers/src/TokenBalancesController.test.ts
index 9d79d33468d..b96a998e1f4 100644
--- a/packages/assets-controllers/src/TokenBalancesController.test.ts
+++ b/packages/assets-controllers/src/TokenBalancesController.test.ts
@@ -2,10 +2,10 @@ import { Messenger } from '@metamask/base-controller';
 import { toHex } from '@metamask/controller-utils';
 import type { NetworkState } from '@metamask/network-controller';
 import type { PreferencesState } from '@metamask/preferences-controller';
+import { CHAIN_IDS } from '@metamask/transaction-controller';
 import BN from 'bn.js';
 import { useFakeTimers } from 'sinon';
 
-import { advanceTime } from '../../../tests/helpers';
 import * as multicall from './multicall';
 import type {
   AllowedActions,
@@ -16,13 +16,18 @@ import type {
 } from './TokenBalancesController';
 import { TokenBalancesController } from './TokenBalancesController';
 import type { TokensControllerState } from './TokensController';
+import { advanceTime } from '../../../tests/helpers';
+import { createMockInternalAccount } from '../../accounts-controller/src/tests/mocks';
+import type { InternalAccount } from '../../transaction-controller/src/types';
 
 const setupController = ({
   config,
   tokens = { allTokens: {}, allDetectedTokens: {} },
+  listAccounts = [],
 }: {
   config?: Partial<ConstructorParameters<typeof TokenBalancesController>[0]>;
   tokens?: Partial<TokensControllerState>;
+  listAccounts?: InternalAccount[];
 } = {}) => {
   const messenger = new Messenger<
     TokenBalancesControllerActions | AllowedActions,
@@ -37,11 +42,13 @@ const setupController = ({
       'PreferencesController:getState',
       'TokensController:getState',
       'AccountsController:getSelectedAccount',
+      'AccountsController:listAccounts',
     ],
     allowedEvents: [
       'NetworkController:stateChange',
       'PreferencesController:stateChange',
       'TokensController:stateChange',
+      'AccountsController:accountRemoved',
     ],
   });
 
@@ -67,6 +74,12 @@ const setupController = ({
     jest.fn().mockImplementation(() => tokens),
   );
 
+  const mockListAccounts = jest.fn().mockReturnValue(listAccounts);
+  messenger.registerActionHandler(
+    'AccountsController:listAccounts',
+    mockListAccounts,
+  );
+
   messenger.registerActionHandler(
     'AccountsController:getSelectedAccount',
     jest.fn().mockImplementation(() => ({
@@ -78,12 +91,15 @@ const setupController = ({
     'NetworkController:getNetworkClientById',
     jest.fn().mockReturnValue({ provider: jest.fn() }),
   );
+  const controller = new TokenBalancesController({
+    messenger: tokenBalancesMessenger,
+    ...config,
+  });
+  const updateSpy = jest.spyOn(controller, 'update' as never);
 
   return {
-    controller: new TokenBalancesController({
-      messenger: tokenBalancesMessenger,
-      ...config,
-    }),
+    controller,
+    updateSpy,
     messenger,
   };
 };
@@ -264,7 +280,7 @@ describe('TokenBalancesController', () => {
       },
     };
 
-    const { controller, messenger } = setupController({
+    const { controller, messenger, updateSpy } = setupController({
       tokens: initialTokens,
     });
 
@@ -302,12 +318,148 @@ describe('TokenBalancesController', () => {
     await advanceTime({ clock, duration: 1 });
 
     // Verify balance was removed
+    expect(updateSpy).toHaveBeenCalledTimes(2);
     expect(controller.state.tokenBalances).toStrictEqual({
       [accountAddress]: {
         [chainId]: {}, // Empty balances object
       },
     });
   });
+  it('skips removing balances when incoming chainIds are not in the current chainIds list for tokenBalances', async () => {
+    const chainId = '0x1';
+    const accountAddress = '0x0000000000000000000000000000000000000000';
+    const tokenAddress = '0x0000000000000000000000000000000000000001';
+
+    // Start with a token
+    const initialTokens = {
+      allDetectedTokens: {},
+      allTokens: {
+        [chainId]: {
+          [accountAddress]: [
+            { address: tokenAddress, symbol: 's', decimals: 0 },
+          ],
+        },
+      },
+    };
+
+    const { controller, messenger, updateSpy } = setupController({
+      tokens: initialTokens,
+    });
+
+    // Set initial balance
+    const balance = 123456;
+    jest.spyOn(multicall, 'multicallOrFallback').mockResolvedValue([
+      {
+        success: true,
+        value: new BN(balance),
+      },
+    ]);
+
+    await controller._executePoll({ chainId });
+
+    // Verify initial balance is set
+    expect(controller.state.tokenBalances).toStrictEqual({
+      [accountAddress]: {
+        [chainId]: {
+          [tokenAddress]: toHex(balance),
+        },
+      },
+    });
+
+    // Publish an update with no tokens
+    messenger.publish(
+      'TokensController:stateChange',
+      {
+        allDetectedTokens: {},
+        allIgnoredTokens: {},
+        allTokens: { [CHAIN_IDS.BASE]: {} },
+      },
+      [],
+    );
+
+    await advanceTime({ clock, duration: 1 });
+
+    // Verify initial balances are still there
+    expect(updateSpy).toHaveBeenCalledTimes(1); // should be called only once when we first updated the balances and not twice
+    expect(controller.state.tokenBalances).toStrictEqual({
+      [accountAddress]: {
+        [chainId]: {
+          [tokenAddress]: toHex(balance),
+        },
+      },
+    });
+  });
+
+  it('skips removing balances when state change with tokens that are already in tokenBalances state', async () => {
+    const chainId = '0x1';
+    const accountAddress = '0x0000000000000000000000000000000000000000';
+    const tokenAddress = '0x0000000000000000000000000000000000000001';
+
+    // Start with a token
+    const initialTokens = {
+      allDetectedTokens: {},
+      allTokens: {
+        [chainId]: {
+          [accountAddress]: [
+            { address: tokenAddress, symbol: 's', decimals: 0 },
+          ],
+        },
+      },
+    };
+
+    const { controller, messenger, updateSpy } = setupController({
+      tokens: initialTokens,
+    });
+
+    // Set initial balance
+    const balance = 123456;
+    jest.spyOn(multicall, 'multicallOrFallback').mockResolvedValue([
+      {
+        success: true,
+        value: new BN(balance),
+      },
+    ]);
+
+    await controller._executePoll({ chainId });
+
+    // Verify initial balance is set
+    expect(controller.state.tokenBalances).toStrictEqual({
+      [accountAddress]: {
+        [chainId]: {
+          [tokenAddress]: toHex(balance),
+        },
+      },
+    });
+
+    // Publish an update with no tokens
+    messenger.publish(
+      'TokensController:stateChange',
+      {
+        allDetectedTokens: {},
+        allIgnoredTokens: {},
+        allTokens: {
+          [chainId]: {
+            [accountAddress]: [
+              { address: tokenAddress, symbol: 's', decimals: 0 },
+            ],
+          },
+        },
+      },
+      [],
+    );
+
+    await advanceTime({ clock, duration: 1 });
+
+    // Verify initial balances are still there
+    expect(updateSpy).toHaveBeenCalledTimes(1); // should be called only once when we first updated the balances and not twice
+    expect(controller.state.tokenBalances).toStrictEqual({
+      [accountAddress]: {
+        [chainId]: {
+          [tokenAddress]: toHex(balance),
+        },
+      },
+    });
+  });
 
   it('updates balances for all accounts when multi-account balances is enabled', async () => {
     const chainId = '0x1';
@@ -357,6 +509,128 @@ describe('TokenBalancesController', () => {
     });
   });
 
+  it('does not update balances when multi-account balances is enabled and all returned values did not change', async () => {
+    const chainId = '0x1';
+    const account1 = '0x0000000000000000000000000000000000000001';
+    const account2 = '0x0000000000000000000000000000000000000002';
+    const tokenAddress = '0x0000000000000000000000000000000000000003';
+
+    const tokens = {
+      allDetectedTokens: {},
+      allTokens: {
+        [chainId]: {
+          [account1]: [{ address: tokenAddress, symbol: 's', decimals: 0 }],
+          [account2]: [{ address: tokenAddress, symbol: 's', decimals: 0 }],
+        },
+      },
+    };
+
+    const { controller, messenger, updateSpy } = setupController({ tokens });
+
+    // Enable multi account balances
+    messenger.publish(
+      'PreferencesController:stateChange',
+      { isMultiAccountBalancesEnabled: true } as PreferencesState,
+      [],
+    );
+
+    const balance1 = 100;
+    const balance2 = 200;
+    jest.spyOn(multicall, 'multicallOrFallback').mockResolvedValue([
+      { success: true, value: new BN(balance1) },
+      { success: true, value: new BN(balance2) },
+    ]);
+
+    await controller._executePoll({ chainId });
+
+    expect(controller.state.tokenBalances).toStrictEqual({
+      [account1]: {
+        [chainId]: {
+          [tokenAddress]: toHex(balance1),
+        },
+      },
+      [account2]: {
+        [chainId]: {
+          [tokenAddress]: toHex(balance2),
+        },
+      },
+    });
+
+    await controller._executePoll({ chainId });
+
+    expect(updateSpy).toHaveBeenCalledTimes(1);
+  });
+
+  it('updates balances when multi-account balances is enabled and some returned values changed', async () => {
+    const chainId = '0x1';
+    const account1 = '0x0000000000000000000000000000000000000001';
+    const account2 = '0x0000000000000000000000000000000000000002';
+    const tokenAddress = '0x0000000000000000000000000000000000000003';
+
+    const tokens = {
+      allDetectedTokens: {},
+      allTokens: {
+        [chainId]: {
+          [account1]: [{ address: tokenAddress, symbol: 's', decimals: 0 }],
+          [account2]: [{ address: tokenAddress, symbol: 's', decimals: 0 }],
+        },
+      },
+    };
+
+    const { controller, messenger, updateSpy } = setupController({ tokens });
+
+    // Enable multi account balances
+    messenger.publish(
+      'PreferencesController:stateChange',
+      { isMultiAccountBalancesEnabled: true } as PreferencesState,
+      [],
+    );
+
+    const balance1 = 100;
+    const balance2 = 200;
+    const balance3 = 300;
+    jest.spyOn(multicall, 'multicallOrFallback').mockResolvedValueOnce([
+      { success: true, value: new BN(balance1) },
+      { success: true, value: new BN(balance2) },
+    ]);
+    jest.spyOn(multicall, 'multicallOrFallback').mockResolvedValueOnce([
+      { success: true, value: new BN(balance1) },
+      { success: true, value: new BN(balance3) },
+    ]);
+
+    await controller._executePoll({ chainId });
+
+    expect(controller.state.tokenBalances).toStrictEqual({
+      [account1]: {
+        [chainId]: {
+          [tokenAddress]: toHex(balance1),
+        },
+      },
+      [account2]: {
+        [chainId]: {
+          [tokenAddress]: toHex(balance2),
+        },
+      },
+    });
+
+    await controller._executePoll({ chainId });
+
+    expect(controller.state.tokenBalances).toStrictEqual({
+      [account1]: {
+        [chainId]: {
+          [tokenAddress]: toHex(balance1),
+        },
+      },
+      [account2]: {
+        [chainId]: {
+          [tokenAddress]: toHex(balance3),
+        },
+      },
+    });
+
+    expect(updateSpy).toHaveBeenCalledTimes(2);
+  });
+
   it('only updates selected account balance when multi-account balances is disabled', async () => {
     const chainId = '0x1';
     const selectedAccount = '0x0000000000000000000000000000000000000000';
@@ -471,4 +745,94 @@ describe('TokenBalancesController', () => {
       });
     });
   });
+
+  describe('when accountRemoved is published', () => {
+    it('does not update state if account removed is not in the list of accounts', async () => {
+      const { controller, messenger, updateSpy } = setupController();
+
+      messenger.publish(
+        'AccountsController:accountRemoved',
+        '0x0000000000000000000000000000000000000000',
+      );
+
+      expect(controller.state.tokenBalances).toStrictEqual({});
+      expect(updateSpy).toHaveBeenCalledTimes(0);
+    });
+    it('removes the balances for the removed account', async () => {
+      const chainId = '0x1';
+      const accountAddress = '0x0000000000000000000000000000000000000000';
+      const accountAddress2 = '0x0000000000000000000000000000000000000002';
+      const tokenAddress = '0x0000000000000000000000000000000000000001';
+      const tokenAddress2 = '0x0000000000000000000000000000000000000022';
+      const account = createMockInternalAccount({
+        address: accountAddress,
+      });
+      const account2 = createMockInternalAccount({
+        address: accountAddress2,
+      });
+
+      const tokens = {
+        allDetectedTokens: {},
+        allTokens: {
+          [chainId]: {
+            [accountAddress]: [
+              { address: tokenAddress, symbol: 's', decimals: 0 },
+            ],
+            [accountAddress2]: [
+              { address: tokenAddress2, symbol: 't', decimals: 0 },
+            ],
+          },
+        },
+      };
+
+      const { controller, messenger } = setupController({
+        tokens,
+        listAccounts: [account, account2],
+      });
+      // Enable multi account balances
+      messenger.publish(
+        'PreferencesController:stateChange',
+        { isMultiAccountBalancesEnabled: true } as PreferencesState,
+        [],
+      );
+      expect(controller.state.tokenBalances).toStrictEqual({});
+
+      const balance = 123456;
+      const balance2 = 200;
+      jest.spyOn(multicall, 'multicallOrFallback').mockResolvedValue([
+        {
+          success: true,
+          value: new BN(balance),
+        },
+        { success: true, value: new BN(balance2) },
+      ]);
+
+      await controller._executePoll({ chainId });
+
+      expect(controller.state.tokenBalances).toStrictEqual({
+        [accountAddress]: {
+          [chainId]: {
+            [tokenAddress]: toHex(balance),
+          },
+        },
+        [accountAddress2]: {
+          [chainId]: {
+            [tokenAddress2]: toHex(balance2),
+          },
+        },
+      });
+
+      messenger.publish('AccountsController:accountRemoved', account.id);
+
+      await advanceTime({ clock, duration: 1 });
+
+      expect(controller.state.tokenBalances).toStrictEqual({
+        [accountAddress2]: {
+          [chainId]: {
+            [tokenAddress2]: toHex(balance2),
+          },
+        },
+      });
+    });
+  });
 });
diff --git a/packages/assets-controllers/src/TokenBalancesController.ts b/packages/assets-controllers/src/TokenBalancesController.ts
index 5c57b3eabe2..62a667f9073 100644
--- a/packages/assets-controllers/src/TokenBalancesController.ts
+++ b/packages/assets-controllers/src/TokenBalancesController.ts
@@ -1,6 +1,10 @@
 import { Contract } from '@ethersproject/contracts';
 import { Web3Provider } from '@ethersproject/providers';
-import type { AccountsControllerGetSelectedAccountAction } from '@metamask/accounts-controller';
+import type {
+  AccountsControllerAccountRemovedEvent,
+  AccountsControllerGetSelectedAccountAction,
+  AccountsControllerListAccountsAction,
+} from '@metamask/accounts-controller';
 import type {
   RestrictedMessenger,
   ControllerGetStateAction,
@@ -80,7 +84,8 @@ export type AllowedActions =
   | NetworkControllerGetStateAction
   | TokensControllerGetStateAction
   | PreferencesControllerGetStateAction
-  | AccountsControllerGetSelectedAccountAction;
+  | AccountsControllerGetSelectedAccountAction
+  | AccountsControllerListAccountsAction;
 
 export type TokenBalancesControllerStateChangeEvent =
   ControllerStateChangeEvent<
@@ -94,7 +99,8 @@ export type TokenBalancesControllerEvents =
 export type AllowedEvents =
   | TokensControllerStateChangeEvent
   | PreferencesControllerStateChangeEvent
-  | NetworkControllerStateChangeEvent;
+  | NetworkControllerStateChangeEvent
+  | AccountsControllerAccountRemovedEvent;
 
 export type TokenBalancesControllerMessenger = RestrictedMessenger<
   typeof controllerName,
@@ -185,6 +191,13 @@ export class TokenBalancesController extends StaticIntervalPollingController<Tok
       'NetworkController:stateChange',
       this.#onNetworkStateChange.bind(this),
     );
+
+    // subscribe to account removed event to cleanup stale balances
+
+    this.messagingSystem.subscribe(
+      'AccountsController:accountRemoved',
+      (accountId: string) => this.#handleOnAccountRemoved(accountId),
+    );
   }
 
   /**
@@ -242,8 +255,9 @@ export class TokenBalancesController extends StaticIntervalPollingController<Tok
 
     this.#allTokens = allTokens;
     this.#allDetectedTokens = allDetectedTokens;
-
-    this.updateBalances({ chainIds: chainIdsToUpdate }).catch(console.error);
+    this.#handleTokensControllerStateChange({
+      chainIds: chainIdsToUpdate,
+    }).catch(console.error);
   };
 
   /**
@@ -269,6 +283,27 @@ export class TokenBalancesController extends StaticIntervalPollingController<Tok
     }
   }
 
+  /**
+   * Handles changes when an account has been removed.
+   *
+   * @param accountId - The account id being removed.
+   */
+  #handleOnAccountRemoved(accountId: string) {
+    const accounts = this.messagingSystem.call(
+      'AccountsController:listAccounts',
+    );
+    const accountAddress = accounts.find(
+      (account) => account.id === accountId,
+    )?.address;
+    if (!accountAddress) {
+      return;
+    }
+
+    this.update((state) => {
+      delete state.tokenBalances[accountAddress as `0x${string}`];
+    });
+  }
+
   /**
    * Returns an array of chain ids that have tokens.
    * @param allTokens - The state for imported tokens across all chains.
@@ -309,6 +344,72 @@ export class TokenBalancesController extends StaticIntervalPollingController<Tok
     );
   }
 
+  async #handleTokensControllerStateChange({
+    chainIds,
+  }: { chainIds?: Hex[] } = {}) {
+    const currentTokenBalancesState = this.messagingSystem.call(
+      'TokenBalancesController:getState',
+    );
+    const currentTokenBalances = currentTokenBalancesState.tokenBalances;
+    const currentAllTokens = this.#allTokens;
+    const chainIdsSet = new Set(chainIds);
+
+    // first we check if the state change was due to a token being removed
+    for (const currentAccount of Object.keys(currentTokenBalances)) {
+      const allChains = currentTokenBalances[currentAccount as `0x${string}`];
+      for (const currentChain of Object.keys(allChains)) {
+        if (chainIds?.length && !chainIdsSet.has(currentChain as Hex)) {
+          continue;
+        }
+        const tokensObject = allChains[currentChain as Hex];
+        const allCurrentTokens = Object.keys(tokensObject);
+        const existingTokensInState =
+          currentAllTokens[currentChain as Hex]?.[
+            currentAccount as `0x${string}`
+          ] || [];
+        const existingSet = new Set(
+          existingTokensInState.map((elm) => elm.address),
+        );
+
+        for (const singleToken of allCurrentTokens) {
+          if (!existingSet.has(singleToken)) {
+            this.update((state) => {
+              delete state.tokenBalances[currentAccount as Hex][
+                currentChain as Hex
+              ][singleToken as `0x${string}`];
+            });
+          }
+        }
+      }
+    }
+
+    // then we check if the state change was due to a token being added
+    let shouldUpdate = false;
+    for (const currentChain of Object.keys(currentAllTokens)) {
+      if (chainIds?.length && !chainIdsSet.has(currentChain as Hex)) {
+        continue;
+      }
+      const accountsPerChain = currentAllTokens[currentChain as Hex];
+
+      for (const currentAccount of Object.keys(accountsPerChain)) {
+        const tokensList = accountsPerChain[currentAccount as `0x${string}`];
+        const tokenBalancesObject =
+          currentTokenBalances[currentAccount as `0x${string}`]?.[
+            currentChain as Hex
+          ] || {};
+        for (const singleToken of tokensList) {
+          if (!tokenBalancesObject?.[singleToken.address as `0x${string}`]) {
+            shouldUpdate = true;
+            break;
+          }
+        }
+      }
+    }
+    if (shouldUpdate) {
+      await this.updateBalances({ chainIds }).catch(console.error);
+    }
+  }
+
   /**
    * Updates token balances for the given chain id.
    * @param input - The input for the update.
@@ -341,6 +442,10 @@ export class TokenBalancesController extends StaticIntervalPollingController<Tok
 
     let results: MulticallResult[] = [];
 
+    const currentTokenBalances = this.messagingSystem.call(
+      'TokenBalancesController:getState',
+    );
+
     if (accountTokenPairs.length > 0) {
       const provider = new Web3Provider(
         this.#getNetworkClient(chainId).provider,
@@ -357,18 +462,34 @@ export class TokenBalancesController extends StaticIntervalPollingController<Tok
       results = await multicallOrFallback(calls, chainId, provider);
     }
 
-    this.update((state) => {
-      // Reset so that when accounts or tokens are removed,
-      // their balances are removed rather than left stale.
-      for (const accountAddress of Object.keys(state.tokenBalances)) {
-        state.tokenBalances[accountAddress as Hex][chainId] = {};
-      }
+    const updatedResults: (MulticallResult & {
+      isTokenBalanceValueChanged?: boolean;
+    })[] = results.map((res, i) => {
+      const { value } = res;
+      const { accountAddress, tokenAddress } = accountTokenPairs[i];
+      const currentTokenBalanceValueForAccount =
+        currentTokenBalances.tokenBalances?.[accountAddress]?.[chainId]?.[
+          tokenAddress
+        ];
+      const isTokenBalanceValueChanged =
+        currentTokenBalanceValueForAccount !== toHex(value as BN);
+      return {
+        ...res,
+        isTokenBalanceValueChanged,
+      };
+    });
 
-      for (let i = 0; i < results.length; i++) {
-        const { success, value } = results[i];
-        const { accountAddress, tokenAddress } = accountTokenPairs[i];
+    // if all values of isTokenBalanceValueChanged are false, return
+    if (updatedResults.every((result) => !result.isTokenBalanceValueChanged)) {
+      return;
+    }
 
-        if (success) {
+    this.update((state) => {
+      for (let i = 0; i < updatedResults.length; i++) {
+        const { success, value, isTokenBalanceValueChanged } =
+          updatedResults[i];
+        const { accountAddress, tokenAddress } = accountTokenPairs[i];
+        if (success && isTokenBalanceValueChanged) {
           ((state.tokenBalances[accountAddress] ??= {})[chainId] ??= {})[
             tokenAddress
           ] = toHex(value as BN);
diff --git a/packages/assets-controllers/src/TokensController.test.ts b/packages/assets-controllers/src/TokensController.test.ts
index 5a691ccb597..5a241d4b527 100644
--- a/packages/assets-controllers/src/TokensController.test.ts
+++ b/packages/assets-controllers/src/TokensController.test.ts
@@ -3339,6 +3339,136 @@ describe('TokensController', () => {
       );
     });
   });
+
+  describe('when accountRemoved is published', () => {
+    it('removes the list of tokens for the removed account', async () => {
+      const firstAddress = '0x123';
+      const secondAddress = '0x456';
+      const firstAccount = createMockInternalAccount({
+        address: firstAddress,
+      });
+      const secondAccount = createMockInternalAccount({
+        address: secondAddress,
+      });
+      const initialState: TokensControllerState = {
+        allTokens: {
+          [ChainId.mainnet]: {
+            [firstAddress]: [
+              {
+                address: '0x03',
+                symbol: 'barC',
+                decimals: 2,
+                aggregators: [],
+                image: undefined,
+                name: undefined,
+              },
+            ],
+            [secondAddress]: [
+              {
+                address: '0x04',
+                symbol: 'barD',
+                decimals: 2,
+                aggregators: [],
+                image: undefined,
+                name: undefined,
+              },
+            ],
+          },
+        },
+        allIgnoredTokens: {},
+        allDetectedTokens: {
+          [ChainId.mainnet]: {
+            [firstAddress]: [],
+            [secondAddress]: [],
+          },
+        },
+      };
+      await withController(
+        {
+          options: {
+            state: initialState,
+          },
+          listAccounts: [firstAccount, secondAccount],
+        },
+        ({ controller, triggerAccountRemoved }) => {
+          expect(controller.state).toStrictEqual(initialState);
+
+          triggerAccountRemoved(firstAccount.id);
+
+          expect(controller.state).toStrictEqual({
+            allTokens: {
+              [ChainId.mainnet]: {
+                [secondAddress]: [
+                  {
+                    address: '0x04',
+                    symbol: 'barD',
+                    decimals: 2,
+                    aggregators: [],
+                    image: undefined,
+                    name: undefined,
+                  },
+                ],
+              },
+            },
+            allIgnoredTokens: {},
+            allDetectedTokens: {
+              [ChainId.mainnet]: {
+                [secondAddress]: [],
+              },
+            },
+          });
+        },
+      );
+    });
+
+    it('removes an account with no tokens', async () => {
+      const firstAddress = '0x123';
+      const secondAddress = '0x456';
+      const firstAccount = createMockInternalAccount({
+        address: firstAddress,
+      });
+      const secondAccount = createMockInternalAccount({
+        address: secondAddress,
+      });
+      const initialState: TokensControllerState = {
+        allTokens: {
+          [ChainId.mainnet]: {
+            [firstAddress]: [
+              {
+                address: '0x03',
+                symbol: 'barC',
+                decimals: 2,
+                aggregators: [],
+                image: undefined,
+                name: undefined,
+              },
+            ],
+          },
+        },
+        allIgnoredTokens: {},
+        allDetectedTokens: {
+          [ChainId.mainnet]: {
+            [firstAddress]: [],
+          },
+        },
+      };
+      await withController(
+        {
+          options: {
+            state: initialState,
+          },
+          listAccounts: [firstAccount, secondAccount],
+        },
+        ({ controller, triggerAccountRemoved }) => {
+          expect(controller.state).toStrictEqual(initialState);
+
+          triggerAccountRemoved(secondAccount.id);
+
+          expect(controller.state).toStrictEqual(initialState);
+        },
+      );
+    });
+  });
 });
 
 type WithControllerCallback<ReturnValue> = ({
@@ -3347,6 +3477,7 @@ type WithControllerCallback<ReturnValue> = ({
   messenger,
   approvalController,
   triggerSelectedAccountChange,
+  triggerAccountRemoved,
 }: {
   controller: TokensController;
   changeNetwork: (networkControllerState: {
@@ -3355,6 +3486,7 @@ type WithControllerCallback<ReturnValue> = ({
   messenger: UnrestrictedMessenger;
   approvalController: ApprovalController;
   triggerSelectedAccountChange: (internalAccount: InternalAccount) => void;
+  triggerAccountRemoved: (accountId: string) => void;
   triggerNetworkStateChange: (
     networkState: NetworkState,
     patches: Patch[],
@@ -3378,6 +3510,7 @@ type WithControllerArgs<ReturnValue> =
           NetworkClientConfiguration
         >;
         mocks?: WithControllerMockArgs;
+        listAccounts?: InternalAccount[];
       },
       WithControllerCallback<ReturnValue>,
     ];
@@ -3403,6 +3536,7 @@ async function withController<ReturnValue>(
       options = {},
       mockNetworkClientConfigurationsByNetworkClientId = {},
       mocks = {} as WithControllerMockArgs,
+      listAccounts = [],
     },
     fn,
   ] = args.length === 2 ? args : [{}, args[0]];
@@ -3427,12 +3561,14 @@ async function withController<ReturnValue>(
       'NetworkController:getNetworkClientById',
       'AccountsController:getAccount',
       'AccountsController:getSelectedAccount',
+      'AccountsController:listAccounts',
     ],
     allowedEvents: [
       'NetworkController:networkDidChange',
       'NetworkController:stateChange',
       'AccountsController:selectedEvmAccountChange',
       'TokenListController:stateChange',
+      'AccountsController:accountRemoved',
     ],
   });
 
@@ -3452,6 +3588,12 @@ async function withController<ReturnValue>(
     ),
   );
 
+  const mockListAccounts = jest.fn().mockReturnValue(listAccounts);
+  messenger.registerActionHandler(
+    'AccountsController:listAccounts',
+    mockListAccounts,
+  );
+
   const controller = new TokensController({
     chainId: ChainId.mainnet,
     // The tests assume that this is set, but they shouldn't make that
@@ -3471,6 +3613,10 @@ async function withController<ReturnValue>(
     );
   };
 
+  const triggerAccountRemoved = (accountId: string) => {
+    messenger.publish('AccountsController:accountRemoved', accountId);
+  };
+
   const changeNetwork = ({
     selectedNetworkClientId,
   }: {
@@ -3504,6 +3650,7 @@ async function withController<ReturnValue>(
     approvalController,
     triggerSelectedAccountChange,
     triggerNetworkStateChange,
+    triggerAccountRemoved,
     getAccountHandler,
     getSelectedAccountHandler,
   });
diff --git a/packages/assets-controllers/src/TokensController.ts b/packages/assets-controllers/src/TokensController.ts
index 1260f5fd672..3cac5e0920a 100644
--- a/packages/assets-controllers/src/TokensController.ts
+++ b/packages/assets-controllers/src/TokensController.ts
@@ -1,8 +1,10 @@
 import { Contract } from '@ethersproject/contracts';
 import { Web3Provider } from '@ethersproject/providers';
 import type {
+  AccountsControllerAccountRemovedEvent,
   AccountsControllerGetAccountAction,
   AccountsControllerGetSelectedAccountAction,
+  AccountsControllerListAccountsAction,
   AccountsControllerSelectedEvmAccountChangeEvent,
 } from '@metamask/accounts-controller';
 import type { AddApprovalRequest } from '@metamask/approval-controller';
@@ -124,7 +126,8 @@ export type AllowedActions =
   | AddApprovalRequest
   | NetworkControllerGetNetworkClientByIdAction
   | AccountsControllerGetAccountAction
-  | AccountsControllerGetSelectedAccountAction;
+  | AccountsControllerGetSelectedAccountAction
+  | AccountsControllerListAccountsAction;
 
 export type TokensControllerStateChangeEvent = ControllerStateChangeEvent<
   typeof controllerName,
@@ -137,7 +140,8 @@ export type AllowedEvents =
   | NetworkControllerStateChangeEvent
   | NetworkControllerNetworkDidChangeEvent
   | TokenListStateChange
-  | AccountsControllerSelectedEvmAccountChangeEvent;
+  | AccountsControllerSelectedEvmAccountChangeEvent
+  | AccountsControllerAccountRemovedEvent;
 
 /**
  * The messenger of the {@link TokensController}.
@@ -223,6 +227,12 @@ export class TokensController extends BaseController<
       this.#onNetworkStateChange.bind(this),
     );
 
+    this.messagingSystem.subscribe(
+      'AccountsController:accountRemoved',
+      (accountAddress: string) =>
+        this.#handleOnAccountRemoved(accountAddress as Hex),
+    );
+
     this.messagingSystem.subscribe(
       'TokenListController:stateChange',
       ({ tokensChainsCache }) => {
@@ -260,6 +270,48 @@ export class TokensController extends BaseController<
     );
   }
 
+  #handleOnAccountRemoved(accountId: string) {
+    // find the account address in allTokens, allDetectedTokens, allIgnoredTokens
+    const { allTokens, allIgnoredTokens, allDetectedTokens } = this.state;
+    const accounts = this.messagingSystem.call(
+      'AccountsController:listAccounts',
+    );
+    const accountAddress = accounts.find(
+      (account) => account.id === accountId,
+    )?.address;
+
+    if (!accountAddress) {
+      return;
+    }
+    const newAllTokens = cloneDeep(allTokens);
+    const newAllDetectedTokens = cloneDeep(allDetectedTokens);
+    const newAllIgnoredTokens = cloneDeep(allIgnoredTokens);
+
+    for (const chainId of Object.keys(newAllTokens)) {
+      if (newAllTokens[chainId as Hex][accountAddress]) {
+        delete newAllTokens[chainId as Hex][accountAddress];
+      }
+    }
+
+    for (const chainId of Object.keys(newAllDetectedTokens)) {
+      if (newAllDetectedTokens[chainId as Hex][accountAddress]) {
+        delete newAllDetectedTokens[chainId as Hex][accountAddress];
+      }
+    }
+
+    for (const chainId of Object.keys(newAllIgnoredTokens)) {
+      if (newAllIgnoredTokens[chainId as Hex][accountAddress]) {
+        delete newAllIgnoredTokens[chainId as Hex][accountAddress];
+      }
+    }
+
+    this.update((state) => {
+      state.allTokens = newAllTokens;
+      state.allIgnoredTokens = newAllIgnoredTokens;
+      state.allDetectedTokens = newAllDetectedTokens;
+    });
+  }
+
   /**
    * Handles the event when the network state changes.
    * @param _ - The network state.


Reviews:
user: login: github-advanced-security[bot]
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T15:45:01Z
user: login: sahar-fehri
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-01T08:57:28Z
user: login: sahar-fehri
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-01T08:58:04Z
user: login: Prithpal-Sooriya
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-05-01T12:46:27Z
user: login: Prithpal-Sooriya
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T18:46:48Z
user: login: Prithpal-Sooriya
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T18:47:49Z
user: login: Prithpal-Sooriya
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T19:34:10Z
user: login: Prithpal-Sooriya
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T22:16:58Z
user: login: Prithpal-Sooriya
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T22:17:36Z
user: login: sahar-fehri
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-16T13:43:52Z
user: login: Prithpal-Sooriya
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-23T13:56:53Z

Review comments:
diff_hunk: @@ -309,6 +331,39 @@
     );
   }
 
+  #cleanupCurrentTokenBalances(
+    accountTokenPairs: { accountAddress: Hex; tokenAddress: Hex }[],
+    chainId: Hex,
+  ) {
+    // TODO move this to a private fct
+    const currentTokenBalances = this.messagingSystem.call(
+      'TokenBalancesController:getState',
+    );
+
+    // in case currentTokenBalances has a token that is not in accountTokenPairs, we will remove it from currentTokenBalances
+    // loop through currentTokenBalances.tokenBalances, for each account loop though the account[chainId] array of tokens and check if the token is in accountTokenPairs
+    // if not, remove it from currentTokenBalances
+    for (const accountAddress of Object.keys(
+      currentTokenBalances.tokenBalances,
+    )) {
+      for (const tokenAddress of Object.keys(
+        currentTokenBalances.tokenBalances[accountAddress as `0x${string}`][
+          chainId
+        ],
+      )) {
+        if (
+          !accountTokenPairs.some((pair) => pair.tokenAddress === tokenAddress)
+        ) {
+          this.update((state) => {
+            delete state.tokenBalances[accountAddress as Hex][chainId as Hex][
path: packages/assets-controllers/src/TokenBalancesController.ts
user: login: github-advanced-security[bot]
body: ## Prototype-polluting assignment

This assignment may alter Object.prototype if a malicious '__proto__' string is injected from [library input](1).

[Show more details](https://github.com/MetaMask/core/security/code-scanning/35)
created_at: 2025-04-29T15:45:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -357,18 +462,34 @@ export class TokenBalancesController extends StaticIntervalPollingController<Tok
       results = await multicallOrFallback(calls, chainId, provider);
     }
 
-    this.update((state) => {
-      // Reset so that when accounts or tokens are removed,
-      // their balances are removed rather than left stale.
-      for (const accountAddress of Object.keys(state.tokenBalances)) {
path: packages/assets-controllers/src/TokenBalancesController.ts
user: login: sahar-fehri
body: Instead of resetting account balances here; we will explicitly handle the case when an account is removed and when a token is removed
created_at: 2025-05-01T08:57:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -357,18 +462,34 @@ export class TokenBalancesController extends StaticIntervalPollingController<Tok
       results = await multicallOrFallback(calls, chainId, provider);
     }
 
-    this.update((state) => {
-      // Reset so that when accounts or tokens are removed,
-      // their balances are removed rather than left stale.
-      for (const accountAddress of Object.keys(state.tokenBalances)) {
-        state.tokenBalances[accountAddress as Hex][chainId] = {};
-      }
+    const updatedResults: (MulticallResult & {
+      isTokenBalanceValueChanged?: boolean;
+    })[] = results.map((res, i) => {
+      const { value } = res;
+      const { accountAddress, tokenAddress } = accountTokenPairs[i];
+      const currentTokenBalanceValueForAccount =
+        currentTokenBalances.tokenBalances?.[accountAddress]?.[chainId]?.[
+          tokenAddress
+        ];
+      const isTokenBalanceValueChanged =
+        currentTokenBalanceValueForAccount !== toHex(value as BN);
+      return {
+        ...res,
+        isTokenBalanceValueChanged,
+      };
+    });
 
-      for (let i = 0; i < results.length; i++) {
-        const { success, value } = results[i];
-        const { accountAddress, tokenAddress } = accountTokenPairs[i];
+    // if all values of isTokenBalanceValueChanged are false, return
+    if (updatedResults.every((result) => !result.isTokenBalanceValueChanged)) {
+      return;
path: packages/assets-controllers/src/TokenBalancesController.ts
user: login: sahar-fehri
body: If none of the balances has changed, return and dnt update state
created_at: 2025-05-01T08:58:04Z
author_association: CONTRIBUTOR
diff_hunk: @@ -309,6 +344,72 @@ export class TokenBalancesController extends StaticIntervalPollingController<Tok
     );
   }
 
+  async #handleTokensControllerStateChange({
+    chainIds,
+  }: { chainIds?: Hex[] } = {}) {
+    const currentTokenBalancesState = this.messagingSystem.call(
+      'TokenBalancesController:getState',
+    );
+    const currentTokenBalances = currentTokenBalancesState.tokenBalances;
+    const currentAllTokens = this.#allTokens;
+    const chainIdsSet = new Set(chainIds);
+
+    // first we check if the state change was due to a token being removed
+    for (const currentAccount of Object.keys(currentTokenBalances)) {
path: packages/assets-controllers/src/TokenBalancesController.ts
user: login: Prithpal-Sooriya
body: WOW, our objects are difficult to loop/select through! 3 layers of for loops lol.

This is fine as is, I do wonder if there is a way we can process and flatten this to make it a little easier to select through.
created_at: 2025-05-15T18:46:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -309,6 +344,72 @@ export class TokenBalancesController extends StaticIntervalPollingController<Tok
     );
   }
 
+  async #handleTokensControllerStateChange({
+    chainIds,
+  }: { chainIds?: Hex[] } = {}) {
+    const currentTokenBalancesState = this.messagingSystem.call(
+      'TokenBalancesController:getState',
+    );
+    const currentTokenBalances = currentTokenBalancesState.tokenBalances;
+    const currentAllTokens = this.#allTokens;
+    const chainIdsSet = new Set(chainIds);
+
+    // first we check if the state change was due to a token being removed
+    for (const currentAccount of Object.keys(currentTokenBalances)) {
+      const allChains = currentTokenBalances[currentAccount as `0x${string}`];
+      for (const currentChain of Object.keys(allChains)) {
+        if (chainIds?.length && !chainIdsSet.has(currentChain as Hex)) {
+          continue;
+        }
+        const tokensObject = allChains[currentChain as Hex];
+        const allCurrentTokens = Object.keys(tokensObject);
+        const existingTokensInState =
+          currentAllTokens[currentChain as Hex]?.[
+            currentAccount as `0x${string}`
+          ] || [];
+        const existingSet = new Set(
+          existingTokensInState.map((elm) => elm.address),
+        );
+
+        for (const singleToken of allCurrentTokens) {
+          if (!existingSet.has(singleToken)) {
+            this.update((state) => {
+              delete state.tokenBalances[currentAccount as Hex][
+                currentChain as Hex
+              ][singleToken as `0x${string}`];
+            });
+          }
path: packages/assets-controllers/src/TokenBalancesController.ts
user: login: Prithpal-Sooriya
body: Only concern here is that we could potentially be performing multiple `this.update` calls which would impact performance.

I wonder if there is a nicer way where we can group the deleted items and batch delete them at the end?
created_at: 2025-05-15T18:47:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -309,6 +344,72 @@ export class TokenBalancesController extends StaticIntervalPollingController<Tok
     );
   }
 
+  async #handleTokensControllerStateChange({
+    chainIds,
+  }: { chainIds?: Hex[] } = {}) {
+    const currentTokenBalancesState = this.messagingSystem.call(
+      'TokenBalancesController:getState',
+    );
+    const currentTokenBalances = currentTokenBalancesState.tokenBalances;
+    const currentAllTokens = this.#allTokens;
+    const chainIdsSet = new Set(chainIds);
+
+    // first we check if the state change was due to a token being removed
+    for (const currentAccount of Object.keys(currentTokenBalances)) {
+      const allChains = currentTokenBalances[currentAccount as `0x${string}`];
+      for (const currentChain of Object.keys(allChains)) {
+        if (chainIds?.length && !chainIdsSet.has(currentChain as Hex)) {
+          continue;
+        }
+        const tokensObject = allChains[currentChain as Hex];
+        const allCurrentTokens = Object.keys(tokensObject);
+        const existingTokensInState =
+          currentAllTokens[currentChain as Hex]?.[
+            currentAccount as `0x${string}`
+          ] || [];
+        const existingSet = new Set(
+          existingTokensInState.map((elm) => elm.address),
+        );
+
+        for (const singleToken of allCurrentTokens) {
+          if (!existingSet.has(singleToken)) {
+            this.update((state) => {
+              delete state.tokenBalances[currentAccount as Hex][
+                currentChain as Hex
+              ][singleToken as `0x${string}`];
+            });
+          }
path: packages/assets-controllers/src/TokenBalancesController.ts
user: login: Prithpal-Sooriya
body: Ok a lot of proooompting, suggesting, tweaking, and playing around.
But we can store these deletion items in an intermediate result and run this at the end.

<details><summary>Example</summary>

```ts
// first we check if the state change was due to a token being removed
const accountChainPairs = Object.entries(currentTokenBalances).flatMap(
  ([account, chains]) => {
    // For each account, create pairs with each relevant chain
    return Object.entries(chains)
      .filter(
        ([chain]) => !chainIds?.length || chainIdsSet.has(chain as Hex),
      )
      .map(
        ([chain, tokens]) =>
          [account as Hex, chain as Hex, tokens] as const,
      );
  },
);
const itemsToDelete = accountChainPairs.flatMap(
  ([account, chain, tokens]) => {
    const existingTokenAddresses = new Set(
      currentAllTokens[chain]?.[account]?.map((token) => token.address),
    );

    // Find tokens that need to be deleted
    return Object.keys(tokens)
      .filter((token) => !existingTokenAddresses.has(token as Hex))
      .map((token) => [account, chain, token as Hex] as const);
  },
);
if (itemsToDelete.length > 0) {
  this.update((state) => {
    for (const [account, chain, token] of itemsToDelete) {
      delete state.tokenBalances[account][chain][token];
    }
  });
}
```

</details> 
created_at: 2025-05-15T19:34:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -309,6 +344,72 @@ export class TokenBalancesController extends StaticIntervalPollingController<Tok
     );
   }
 
+  async #handleTokensControllerStateChange({
+    chainIds,
+  }: { chainIds?: Hex[] } = {}) {
+    const currentTokenBalancesState = this.messagingSystem.call(
+      'TokenBalancesController:getState',
+    );
+    const currentTokenBalances = currentTokenBalancesState.tokenBalances;
+    const currentAllTokens = this.#allTokens;
+    const chainIdsSet = new Set(chainIds);
+
+    // first we check if the state change was due to a token being removed
+    for (const currentAccount of Object.keys(currentTokenBalances)) {
+      const allChains = currentTokenBalances[currentAccount as `0x${string}`];
+      for (const currentChain of Object.keys(allChains)) {
+        if (chainIds?.length && !chainIdsSet.has(currentChain as Hex)) {
+          continue;
+        }
+        const tokensObject = allChains[currentChain as Hex];
+        const allCurrentTokens = Object.keys(tokensObject);
+        const existingTokensInState =
+          currentAllTokens[currentChain as Hex]?.[
+            currentAccount as `0x${string}`
+          ] || [];
+        const existingSet = new Set(
+          existingTokensInState.map((elm) => elm.address),
+        );
+
+        for (const singleToken of allCurrentTokens) {
+          if (!existingSet.has(singleToken)) {
+            this.update((state) => {
+              delete state.tokenBalances[currentAccount as Hex][
+                currentChain as Hex
+              ][singleToken as `0x${string}`];
+            });
+          }
path: packages/assets-controllers/src/TokenBalancesController.ts
user: login: Prithpal-Sooriya
body: Meh this is fine, realistically how often are we deleting tokens? I don't think it justifies re-architecting.
created_at: 2025-05-15T22:16:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -309,6 +344,72 @@ export class TokenBalancesController extends StaticIntervalPollingController<Tok
     );
   }
 
+  async #handleTokensControllerStateChange({
+    chainIds,
+  }: { chainIds?: Hex[] } = {}) {
+    const currentTokenBalancesState = this.messagingSystem.call(
+      'TokenBalancesController:getState',
+    );
+    const currentTokenBalances = currentTokenBalancesState.tokenBalances;
+    const currentAllTokens = this.#allTokens;
+    const chainIdsSet = new Set(chainIds);
+
+    // first we check if the state change was due to a token being removed
+    for (const currentAccount of Object.keys(currentTokenBalances)) {
+      const allChains = currentTokenBalances[currentAccount as `0x${string}`];
+      for (const currentChain of Object.keys(allChains)) {
+        if (chainIds?.length && !chainIdsSet.has(currentChain as Hex)) {
+          continue;
+        }
+        const tokensObject = allChains[currentChain as Hex];
+        const allCurrentTokens = Object.keys(tokensObject);
+        const existingTokensInState =
+          currentAllTokens[currentChain as Hex]?.[
+            currentAccount as `0x${string}`
+          ] || [];
+        const existingSet = new Set(
+          existingTokensInState.map((elm) => elm.address),
+        );
+
+        for (const singleToken of allCurrentTokens) {
+          if (!existingSet.has(singleToken)) {
+            this.update((state) => {
+              delete state.tokenBalances[currentAccount as Hex][
+                currentChain as Hex
+              ][singleToken as `0x${string}`];
+            });
+          }
path: packages/assets-controllers/src/TokenBalancesController.ts
user: login: sahar-fehri
body: I would say it depends on how often are users removing tokens;

created_at: 2025-05-16T13:43:51Z
author_association: CONTRIBUTOR

PR comments:
user: login: sahar-fehri
created_at: 2025-04-29T15:05:22Z
body: @metamaskbot publish-preview
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-04-29T15:10:36Z
body: 
Preview builds have been published. [See these instructions](https://github.com/MetaMask/core/blob/main/docs/contributing.md#using-packages-in-other-projects-during-developmenttesting) for more information about preview builds.

<details>

<summary>Expand for full list of packages and versions.</summary>


```
{
  "@metamask-previews/accounts-controller": "27.0.0-preview-3300ffa",
  "@metamask-previews/address-book-controller": "6.0.3-preview-3300ffa",
  "@metamask-previews/announcement-controller": "7.0.3-preview-3300ffa",
  "@metamask-previews/app-metadata-controller": "1.0.0-preview-3300ffa",
  "@metamask-previews/approval-controller": "7.1.3-preview-3300ffa",
  "@metamask-previews/assets-controllers": "60.0.0-preview-3300ffa",
  "@metamask-previews/base-controller": "8.0.1-preview-3300ffa",
  "@metamask-previews/bridge-controller": "19.0.0-preview-3300ffa",
  "@metamask-previews/bridge-status-controller": "16.0.0-preview-3300ffa",
  "@metamask-previews/build-utils": "3.0.3-preview-3300ffa",
  "@metamask-previews/chain-agnostic-permission": "0.5.0-preview-3300ffa",
  "@metamask-previews/composable-controller": "11.0.0-preview-3300ffa",
  "@metamask-previews/controller-utils": "11.7.0-preview-3300ffa",
  "@metamask-previews/delegation-controller": "0.1.0-preview-3300ffa",
  "@metamask-previews/earn-controller": "0.12.0-preview-3300ffa",
  "@metamask-previews/eip1193-permission-middleware": "0.1.0-preview-3300ffa",
  "@metamask-previews/ens-controller": "16.0.0-preview-3300ffa",
  "@metamask-previews/eth-json-rpc-provider": "4.1.8-preview-3300ffa",
  "@metamask-previews/gas-fee-controller": "23.0.0-preview-3300ffa",
  "@metamask-previews/json-rpc-engine": "10.0.3-preview-3300ffa",
  "@metamask-previews/json-rpc-middleware-stream": "8.0.7-preview-3300ffa",
  "@metamask-previews/keyring-controller": "21.0.4-preview-3300ffa",
  "@metamask-previews/logging-controller": "6.0.4-preview-3300ffa",
  "@metamask-previews/message-manager": "12.0.1-preview-3300ffa",
  "@metamask-previews/multichain": "4.0.0-preview-3300ffa",
  "@metamask-previews/multichain-api-middleware": "0.2.0-preview-3300ffa",
  "@metamask-previews/multichain-network-controller": "0.5.1-preview-3300ffa",
  "@metamask-previews/multichain-transactions-controller": "0.9.0-preview-3300ffa",
  "@metamask-previews/name-controller": "8.0.3-preview-3300ffa",
  "@metamask-previews/network-controller": "23.2.0-preview-3300ffa",
  "@metamask-previews/notification-services-controller": "6.0.0-preview-3300ffa",
  "@metamask-previews/permission-controller": "11.0.6-preview-3300ffa",
  "@metamask-previews/permission-log-controller": "3.0.3-preview-3300ffa",
  "@metamask-previews/phishing-controller": "12.5.0-preview-3300ffa",
  "@metamask-previews/polling-controller": "13.0.0-preview-3300ffa",
  "@metamask-previews/preferences-controller": "17.0.0-preview-3300ffa",
  "@metamask-previews/profile-sync-controller": "12.0.0-preview-3300ffa",
  "@metamask-previews/queued-request-controller": "10.0.0-preview-3300ffa",
  "@metamask-previews/rate-limit-controller": "6.0.3-preview-3300ffa",
  "@metamask-previews/remote-feature-flag-controller": "1.6.0-preview-3300ffa",
  "@metamask-previews/sample-controllers": "0.1.0-preview-3300ffa",
  "@metamask-previews/selected-network-controller": "22.0.0-preview-3300ffa",
  "@metamask-previews/signature-controller": "27.1.0-preview-3300ffa",
  "@metamask-previews/token-search-discovery-controller": "3.1.0-preview-3300ffa",
  "@metamask-previews/transaction-controller": "54.2.0-preview-3300ffa",
  "@metamask-previews/user-operation-controller": "33.0.0-preview-3300ffa"
}
```

</details>

author_association: CONTRIBUTOR
user: login: sahar-fehri
created_at: 2025-04-29T15:41:07Z
body: @metamaskbot publish-preview
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-04-29T15:46:26Z
body: 
Preview builds have been published. [See these instructions](https://github.com/MetaMask/core/blob/main/docs/contributing.md#using-packages-in-other-projects-during-developmenttesting) for more information about preview builds.

<details>

<summary>Expand for full list of packages and versions.</summary>


```
{
  "@metamask-previews/accounts-controller": "27.0.0-preview-5aa4d0a9",
  "@metamask-previews/address-book-controller": "6.0.3-preview-5aa4d0a9",
  "@metamask-previews/announcement-controller": "7.0.3-preview-5aa4d0a9",
  "@metamask-previews/app-metadata-controller": "1.0.0-preview-5aa4d0a9",
  "@metamask-previews/approval-controller": "7.1.3-preview-5aa4d0a9",
  "@metamask-previews/assets-controllers": "60.0.0-preview-5aa4d0a9",
  "@metamask-previews/base-controller": "8.0.1-preview-5aa4d0a9",
  "@metamask-previews/bridge-controller": "19.0.0-preview-5aa4d0a9",
  "@metamask-previews/bridge-status-controller": "16.0.0-preview-5aa4d0a9",
  "@metamask-previews/build-utils": "3.0.3-preview-5aa4d0a9",
  "@metamask-previews/chain-agnostic-permission": "0.5.0-preview-5aa4d0a9",
  "@metamask-previews/composable-controller": "11.0.0-preview-5aa4d0a9",
  "@metamask-previews/controller-utils": "11.7.0-preview-5aa4d0a9",
  "@metamask-previews/delegation-controller": "0.1.0-preview-5aa4d0a9",
  "@metamask-previews/earn-controller": "0.12.0-preview-5aa4d0a9",
  "@metamask-previews/eip1193-permission-middleware": "0.1.0-preview-5aa4d0a9",
  "@metamask-previews/ens-controller": "16.0.0-preview-5aa4d0a9",
  "@metamask-previews/eth-json-rpc-provider": "4.1.8-preview-5aa4d0a9",
  "@metamask-previews/gas-fee-controller": "23.0.0-preview-5aa4d0a9",
  "@metamask-previews/json-rpc-engine": "10.0.3-preview-5aa4d0a9",
  "@metamask-previews/json-rpc-middleware-stream": "8.0.7-preview-5aa4d0a9",
  "@metamask-previews/keyring-controller": "21.0.4-preview-5aa4d0a9",
  "@metamask-previews/logging-controller": "6.0.4-preview-5aa4d0a9",
  "@metamask-previews/message-manager": "12.0.1-preview-5aa4d0a9",
  "@metamask-previews/multichain": "4.0.0-preview-5aa4d0a9",
  "@metamask-previews/multichain-api-middleware": "0.2.0-preview-5aa4d0a9",
  "@metamask-previews/multichain-network-controller": "0.5.1-preview-5aa4d0a9",
  "@metamask-previews/multichain-transactions-controller": "0.9.0-preview-5aa4d0a9",
  "@metamask-previews/name-controller": "8.0.3-preview-5aa4d0a9",
  "@metamask-previews/network-controller": "23.2.0-preview-5aa4d0a9",
  "@metamask-previews/notification-services-controller": "6.0.0-preview-5aa4d0a9",
  "@metamask-previews/permission-controller": "11.0.6-preview-5aa4d0a9",
  "@metamask-previews/permission-log-controller": "3.0.3-preview-5aa4d0a9",
  "@metamask-previews/phishing-controller": "12.5.0-preview-5aa4d0a9",
  "@metamask-previews/polling-controller": "13.0.0-preview-5aa4d0a9",
  "@metamask-previews/preferences-controller": "17.0.0-preview-5aa4d0a9",
  "@metamask-previews/profile-sync-controller": "12.0.0-preview-5aa4d0a9",
  "@metamask-previews/queued-request-controller": "10.0.0-preview-5aa4d0a9",
  "@metamask-previews/rate-limit-controller": "6.0.3-preview-5aa4d0a9",
  "@metamask-previews/remote-feature-flag-controller": "1.6.0-preview-5aa4d0a9",
  "@metamask-previews/sample-controllers": "0.1.0-preview-5aa4d0a9",
  "@metamask-previews/selected-network-controller": "22.0.0-preview-5aa4d0a9",
  "@metamask-previews/signature-controller": "27.1.0-preview-5aa4d0a9",
  "@metamask-previews/token-search-discovery-controller": "3.1.0-preview-5aa4d0a9",
  "@metamask-previews/transaction-controller": "54.2.0-preview-5aa4d0a9",
  "@metamask-previews/user-operation-controller": "33.0.0-preview-5aa4d0a9"
}
```

</details>

author_association: CONTRIBUTOR
user: login: sahar-fehri
created_at: 2025-04-29T21:36:32Z
body: @metamaskbot publish-preview
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-04-29T21:41:53Z
body: 
Preview builds have been published. [See these instructions](https://github.com/MetaMask/core/blob/main/docs/contributing.md#using-packages-in-other-projects-during-developmenttesting) for more information about preview builds.

<details>

<summary>Expand for full list of packages and versions.</summary>


```
{
  "@metamask-previews/accounts-controller": "27.0.0-preview-aa596380",
  "@metamask-previews/address-book-controller": "6.0.3-preview-aa596380",
  "@metamask-previews/announcement-controller": "7.0.3-preview-aa596380",
  "@metamask-previews/app-metadata-controller": "1.0.0-preview-aa596380",
  "@metamask-previews/approval-controller": "7.1.3-preview-aa596380",
  "@metamask-previews/assets-controllers": "60.0.0-preview-aa596380",
  "@metamask-previews/base-controller": "8.0.1-preview-aa596380",
  "@metamask-previews/bridge-controller": "19.0.0-preview-aa596380",
  "@metamask-previews/bridge-status-controller": "16.0.0-preview-aa596380",
  "@metamask-previews/build-utils": "3.0.3-preview-aa596380",
  "@metamask-previews/chain-agnostic-permission": "0.5.0-preview-aa596380",
  "@metamask-previews/composable-controller": "11.0.0-preview-aa596380",
  "@metamask-previews/controller-utils": "11.7.0-preview-aa596380",
  "@metamask-previews/delegation-controller": "0.1.0-preview-aa596380",
  "@metamask-previews/earn-controller": "0.12.0-preview-aa596380",
  "@metamask-previews/eip1193-permission-middleware": "0.1.0-preview-aa596380",
  "@metamask-previews/ens-controller": "16.0.0-preview-aa596380",
  "@metamask-previews/eth-json-rpc-provider": "4.1.8-preview-aa596380",
  "@metamask-previews/gas-fee-controller": "23.0.0-preview-aa596380",
  "@metamask-previews/json-rpc-engine": "10.0.3-preview-aa596380",
  "@metamask-previews/json-rpc-middleware-stream": "8.0.7-preview-aa596380",
  "@metamask-previews/keyring-controller": "21.0.4-preview-aa596380",
  "@metamask-previews/logging-controller": "6.0.4-preview-aa596380",
  "@metamask-previews/message-manager": "12.0.1-preview-aa596380",
  "@metamask-previews/multichain": "4.0.0-preview-aa596380",
  "@metamask-previews/multichain-api-middleware": "0.2.0-preview-aa596380",
  "@metamask-previews/multichain-network-controller": "0.5.1-preview-aa596380",
  "@metamask-previews/multichain-transactions-controller": "0.9.0-preview-aa596380",
  "@metamask-previews/name-controller": "8.0.3-preview-aa596380",
  "@metamask-previews/network-controller": "23.2.0-preview-aa596380",
  "@metamask-previews/notification-services-controller": "6.0.0-preview-aa596380",
  "@metamask-previews/permission-controller": "11.0.6-preview-aa596380",
  "@metamask-previews/permission-log-controller": "3.0.3-preview-aa596380",
  "@metamask-previews/phishing-controller": "12.5.0-preview-aa596380",
  "@metamask-previews/polling-controller": "13.0.0-preview-aa596380",
  "@metamask-previews/preferences-controller": "17.0.0-preview-aa596380",
  "@metamask-previews/profile-sync-controller": "12.0.0-preview-aa596380",
  "@metamask-previews/queued-request-controller": "10.0.0-preview-aa596380",
  "@metamask-previews/rate-limit-controller": "6.0.3-preview-aa596380",
  "@metamask-previews/remote-feature-flag-controller": "1.6.0-preview-aa596380",
  "@metamask-previews/sample-controllers": "0.1.0-preview-aa596380",
  "@metamask-previews/selected-network-controller": "22.0.0-preview-aa596380",
  "@metamask-previews/signature-controller": "27.1.0-preview-aa596380",
  "@metamask-previews/token-search-discovery-controller": "3.1.0-preview-aa596380",
  "@metamask-previews/transaction-controller": "54.2.0-preview-aa596380",
  "@metamask-previews/user-operation-controller": "33.0.0-preview-aa596380"
}
```

</details>

author_association: CONTRIBUTOR
user: login: sahar-fehri
created_at: 2025-04-29T22:12:06Z
body: @metamaskbot publish-preview
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-04-29T22:17:29Z
body: 
Preview builds have been published. [See these instructions](https://github.com/MetaMask/core/blob/main/docs/contributing.md#using-packages-in-other-projects-during-developmenttesting) for more information about preview builds.

<details>

<summary>Expand for full list of packages and versions.</summary>


```
{
  "@metamask-previews/accounts-controller": "27.0.0-preview-f94f70f2",
  "@metamask-previews/address-book-controller": "6.0.3-preview-f94f70f2",
  "@metamask-previews/announcement-controller": "7.0.3-preview-f94f70f2",
  "@metamask-previews/app-metadata-controller": "1.0.0-preview-f94f70f2",
  "@metamask-previews/approval-controller": "7.1.3-preview-f94f70f2",
  "@metamask-previews/assets-controllers": "60.0.0-preview-f94f70f2",
  "@metamask-previews/base-controller": "8.0.1-preview-f94f70f2",
  "@metamask-previews/bridge-controller": "19.0.0-preview-f94f70f2",
  "@metamask-previews/bridge-status-controller": "16.0.0-preview-f94f70f2",
  "@metamask-previews/build-utils": "3.0.3-preview-f94f70f2",
  "@metamask-previews/chain-agnostic-permission": "0.5.0-preview-f94f70f2",
  "@metamask-previews/composable-controller": "11.0.0-preview-f94f70f2",
  "@metamask-previews/controller-utils": "11.7.0-preview-f94f70f2",
  "@metamask-previews/delegation-controller": "0.1.0-preview-f94f70f2",
  "@metamask-previews/earn-controller": "0.12.0-preview-f94f70f2",
  "@metamask-previews/eip1193-permission-middleware": "0.1.0-preview-f94f70f2",
  "@metamask-previews/ens-controller": "16.0.0-preview-f94f70f2",
  "@metamask-previews/eth-json-rpc-provider": "4.1.8-preview-f94f70f2",
  "@metamask-previews/gas-fee-controller": "23.0.0-preview-f94f70f2",
  "@metamask-previews/json-rpc-engine": "10.0.3-preview-f94f70f2",
  "@metamask-previews/json-rpc-middleware-stream": "8.0.7-preview-f94f70f2",
  "@metamask-previews/keyring-controller": "21.0.4-preview-f94f70f2",
  "@metamask-previews/logging-controller": "6.0.4-preview-f94f70f2",
  "@metamask-previews/message-manager": "12.0.1-preview-f94f70f2",
  "@metamask-previews/multichain": "4.0.0-preview-f94f70f2",
  "@metamask-previews/multichain-api-middleware": "0.2.0-preview-f94f70f2",
  "@metamask-previews/multichain-network-controller": "0.5.1-preview-f94f70f2",
  "@metamask-previews/multichain-transactions-controller": "0.9.0-preview-f94f70f2",
  "@metamask-previews/name-controller": "8.0.3-preview-f94f70f2",
  "@metamask-previews/network-controller": "23.2.0-preview-f94f70f2",
  "@metamask-previews/notification-services-controller": "6.0.0-preview-f94f70f2",
  "@metamask-previews/permission-controller": "11.0.6-preview-f94f70f2",
  "@metamask-previews/permission-log-controller": "3.0.3-preview-f94f70f2",
  "@metamask-previews/phishing-controller": "12.5.0-preview-f94f70f2",
  "@metamask-previews/polling-controller": "13.0.0-preview-f94f70f2",
  "@metamask-previews/preferences-controller": "17.0.0-preview-f94f70f2",
  "@metamask-previews/profile-sync-controller": "12.0.0-preview-f94f70f2",
  "@metamask-previews/queued-request-controller": "10.0.0-preview-f94f70f2",
  "@metamask-previews/rate-limit-controller": "6.0.3-preview-f94f70f2",
  "@metamask-previews/remote-feature-flag-controller": "1.6.0-preview-f94f70f2",
  "@metamask-previews/sample-controllers": "0.1.0-preview-f94f70f2",
  "@metamask-previews/selected-network-controller": "22.0.0-preview-f94f70f2",
  "@metamask-previews/signature-controller": "27.1.0-preview-f94f70f2",
  "@metamask-previews/token-search-discovery-controller": "3.1.0-preview-f94f70f2",
  "@metamask-previews/transaction-controller": "54.2.0-preview-f94f70f2",
  "@metamask-previews/user-operation-controller": "33.0.0-preview-f94f70f2"
}
```

</details>

author_association: CONTRIBUTOR
user: login: sahar-fehri
created_at: 2025-04-30T17:44:40Z
body: @metamaskbot publish-preview
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-04-30T17:50:39Z
body: 
Preview builds have been published. [See these instructions](https://github.com/MetaMask/core/blob/main/docs/contributing.md#using-packages-in-other-projects-during-developmenttesting) for more information about preview builds.

<details>

<summary>Expand for full list of packages and versions.</summary>


```
{
  "@metamask-previews/accounts-controller": "27.0.0-preview-5a5b4df5",
  "@metamask-previews/address-book-controller": "6.0.3-preview-5a5b4df5",
  "@metamask-previews/announcement-controller": "7.0.3-preview-5a5b4df5",
  "@metamask-previews/app-metadata-controller": "1.0.0-preview-5a5b4df5",
  "@metamask-previews/approval-controller": "7.1.3-preview-5a5b4df5",
  "@metamask-previews/assets-controllers": "60.0.0-preview-5a5b4df5",
  "@metamask-previews/base-controller": "8.0.1-preview-5a5b4df5",
  "@metamask-previews/bridge-controller": "19.0.0-preview-5a5b4df5",
  "@metamask-previews/bridge-status-controller": "16.0.0-preview-5a5b4df5",
  "@metamask-previews/build-utils": "3.0.3-preview-5a5b4df5",
  "@metamask-previews/chain-agnostic-permission": "0.5.0-preview-5a5b4df5",
  "@metamask-previews/composable-controller": "11.0.0-preview-5a5b4df5",
  "@metamask-previews/controller-utils": "11.7.0-preview-5a5b4df5",
  "@metamask-previews/delegation-controller": "0.1.0-preview-5a5b4df5",
  "@metamask-previews/earn-controller": "0.12.0-preview-5a5b4df5",
  "@metamask-previews/eip1193-permission-middleware": "0.1.0-preview-5a5b4df5",
  "@metamask-previews/ens-controller": "16.0.0-preview-5a5b4df5",
  "@metamask-previews/eth-json-rpc-provider": "4.1.8-preview-5a5b4df5",
  "@metamask-previews/gas-fee-controller": "23.0.0-preview-5a5b4df5",
  "@metamask-previews/json-rpc-engine": "10.0.3-preview-5a5b4df5",
  "@metamask-previews/json-rpc-middleware-stream": "8.0.7-preview-5a5b4df5",
  "@metamask-previews/keyring-controller": "21.0.4-preview-5a5b4df5",
  "@metamask-previews/logging-controller": "6.0.4-preview-5a5b4df5",
  "@metamask-previews/message-manager": "12.0.1-preview-5a5b4df5",
  "@metamask-previews/multichain": "4.0.0-preview-5a5b4df5",
  "@metamask-previews/multichain-api-middleware": "0.2.0-preview-5a5b4df5",
  "@metamask-previews/multichain-network-controller": "0.5.1-preview-5a5b4df5",
  "@metamask-previews/multichain-transactions-controller": "0.9.0-preview-5a5b4df5",
  "@metamask-previews/name-controller": "8.0.3-preview-5a5b4df5",
  "@metamask-previews/network-controller": "23.2.0-preview-5a5b4df5",
  "@metamask-previews/notification-services-controller": "6.0.0-preview-5a5b4df5",
  "@metamask-previews/permission-controller": "11.0.6-preview-5a5b4df5",
  "@metamask-previews/permission-log-controller": "3.0.3-preview-5a5b4df5",
  "@metamask-previews/phishing-controller": "12.5.0-preview-5a5b4df5",
  "@metamask-previews/polling-controller": "13.0.0-preview-5a5b4df5",
  "@metamask-previews/preferences-controller": "17.0.0-preview-5a5b4df5",
  "@metamask-previews/profile-sync-controller": "12.0.0-preview-5a5b4df5",
  "@metamask-previews/queued-request-controller": "10.0.0-preview-5a5b4df5",
  "@metamask-previews/rate-limit-controller": "6.0.3-preview-5a5b4df5",
  "@metamask-previews/remote-feature-flag-controller": "1.6.0-preview-5a5b4df5",
  "@metamask-previews/sample-controllers": "0.1.0-preview-5a5b4df5",
  "@metamask-previews/selected-network-controller": "22.0.0-preview-5a5b4df5",
  "@metamask-previews/signature-controller": "27.1.0-preview-5a5b4df5",
  "@metamask-previews/token-search-discovery-controller": "3.1.0-preview-5a5b4df5",
  "@metamask-previews/transaction-controller": "54.2.0-preview-5a5b4df5",
  "@metamask-previews/user-operation-controller": "33.0.0-preview-5a5b4df5"
}
```

</details>

author_association: CONTRIBUTOR
user: login: sahar-fehri
created_at: 2025-05-02T13:49:49Z
body: @metamaskbot publish-preview
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-05-02T13:55:08Z
body: 
Preview builds have been published. [See these instructions](https://github.com/MetaMask/core/blob/main/docs/contributing.md#using-packages-in-other-projects-during-developmenttesting) for more information about preview builds.

<details>

<summary>Expand for full list of packages and versions.</summary>


```
{
  "@metamask-previews/accounts-controller": "27.0.0-preview-5ea27f10",
  "@metamask-previews/address-book-controller": "6.0.3-preview-5ea27f10",
  "@metamask-previews/announcement-controller": "7.0.3-preview-5ea27f10",
  "@metamask-previews/app-metadata-controller": "1.0.0-preview-5ea27f10",
  "@metamask-previews/approval-controller": "7.1.3-preview-5ea27f10",
  "@metamask-previews/assets-controllers": "60.0.0-preview-5ea27f10",
  "@metamask-previews/base-controller": "8.0.1-preview-5ea27f10",
  "@metamask-previews/bridge-controller": "19.0.0-preview-5ea27f10",
  "@metamask-previews/bridge-status-controller": "16.0.0-preview-5ea27f10",
  "@metamask-previews/build-utils": "3.0.3-preview-5ea27f10",
  "@metamask-previews/chain-agnostic-permission": "0.5.0-preview-5ea27f10",
  "@metamask-previews/composable-controller": "11.0.0-preview-5ea27f10",
  "@metamask-previews/controller-utils": "11.7.0-preview-5ea27f10",
  "@metamask-previews/delegation-controller": "0.1.0-preview-5ea27f10",
  "@metamask-previews/earn-controller": "0.12.0-preview-5ea27f10",
  "@metamask-previews/eip1193-permission-middleware": "0.1.0-preview-5ea27f10",
  "@metamask-previews/ens-controller": "16.0.0-preview-5ea27f10",
  "@metamask-previews/eth-json-rpc-provider": "4.1.8-preview-5ea27f10",
  "@metamask-previews/gas-fee-controller": "23.0.0-preview-5ea27f10",
  "@metamask-previews/json-rpc-engine": "10.0.3-preview-5ea27f10",
  "@metamask-previews/json-rpc-middleware-stream": "8.0.7-preview-5ea27f10",
  "@metamask-previews/keyring-controller": "21.0.4-preview-5ea27f10",
  "@metamask-previews/logging-controller": "6.0.4-preview-5ea27f10",
  "@metamask-previews/message-manager": "12.0.1-preview-5ea27f10",
  "@metamask-previews/multichain": "4.0.0-preview-5ea27f10",
  "@metamask-previews/multichain-api-middleware": "0.2.0-preview-5ea27f10",
  "@metamask-previews/multichain-network-controller": "0.5.1-preview-5ea27f10",
  "@metamask-previews/multichain-transactions-controller": "0.9.0-preview-5ea27f10",
  "@metamask-previews/name-controller": "8.0.3-preview-5ea27f10",
  "@metamask-previews/network-controller": "23.2.0-preview-5ea27f10",
  "@metamask-previews/notification-services-controller": "6.0.0-preview-5ea27f10",
  "@metamask-previews/permission-controller": "11.0.6-preview-5ea27f10",
  "@metamask-previews/permission-log-controller": "3.0.3-preview-5ea27f10",
  "@metamask-previews/phishing-controller": "12.5.0-preview-5ea27f10",
  "@metamask-previews/polling-controller": "13.0.0-preview-5ea27f10",
  "@metamask-previews/preferences-controller": "17.0.0-preview-5ea27f10",
  "@metamask-previews/profile-sync-controller": "12.0.0-preview-5ea27f10",
  "@metamask-previews/queued-request-controller": "10.0.0-preview-5ea27f10",
  "@metamask-previews/rate-limit-controller": "6.0.3-preview-5ea27f10",
  "@metamask-previews/remote-feature-flag-controller": "1.6.0-preview-5ea27f10",
  "@metamask-previews/sample-controllers": "0.1.0-preview-5ea27f10",
  "@metamask-previews/selected-network-controller": "22.0.0-preview-5ea27f10",
  "@metamask-previews/signature-controller": "27.1.0-preview-5ea27f10",
  "@metamask-previews/token-search-discovery-controller": "3.1.0-preview-5ea27f10",
  "@metamask-previews/transaction-controller": "54.2.0-preview-5ea27f10",
  "@metamask-previews/user-operation-controller": "33.0.0-preview-5ea27f10"
}
```

</details>

author_association: CONTRIBUTOR
user: login: sahar-fehri
created_at: 2025-05-14T11:06:23Z
body: @metamaskbot publish-preview
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-05-14T11:11:44Z
body: 
Preview builds have been published. [See these instructions](https://github.com/MetaMask/core/blob/main/docs/contributing.md#using-packages-in-other-projects-during-developmenttesting) for more information about preview builds.

<details>

<summary>Expand for full list of packages and versions.</summary>


```
{
  "@metamask-previews/accounts-controller": "28.0.0-preview-515e9b9c",
  "@metamask-previews/address-book-controller": "6.0.3-preview-515e9b9c",
  "@metamask-previews/announcement-controller": "7.0.3-preview-515e9b9c",
  "@metamask-previews/app-metadata-controller": "1.0.0-preview-515e9b9c",
  "@metamask-previews/approval-controller": "7.1.3-preview-515e9b9c",
  "@metamask-previews/assets-controllers": "62.0.0-preview-515e9b9c",
  "@metamask-previews/base-controller": "8.0.1-preview-515e9b9c",
  "@metamask-previews/bridge-controller": "24.0.0-preview-515e9b9c",
  "@metamask-previews/bridge-status-controller": "20.1.0-preview-515e9b9c",
  "@metamask-previews/build-utils": "3.0.3-preview-515e9b9c",
  "@metamask-previews/chain-agnostic-permission": "0.6.0-preview-515e9b9c",
  "@metamask-previews/composable-controller": "11.0.0-preview-515e9b9c",
  "@metamask-previews/controller-utils": "11.8.0-preview-515e9b9c",
  "@metamask-previews/delegation-controller": "0.2.0-preview-515e9b9c",
  "@metamask-previews/earn-controller": "0.13.0-preview-515e9b9c",
  "@metamask-previews/eip1193-permission-middleware": "0.1.0-preview-515e9b9c",
  "@metamask-previews/ens-controller": "16.0.0-preview-515e9b9c",
  "@metamask-previews/eth-json-rpc-provider": "4.1.8-preview-515e9b9c",
  "@metamask-previews/gas-fee-controller": "23.0.0-preview-515e9b9c",
  "@metamask-previews/json-rpc-engine": "10.0.3-preview-515e9b9c",
  "@metamask-previews/json-rpc-middleware-stream": "8.0.7-preview-515e9b9c",
  "@metamask-previews/keyring-controller": "21.0.6-preview-515e9b9c",
  "@metamask-previews/logging-controller": "6.0.4-preview-515e9b9c",
  "@metamask-previews/message-manager": "12.0.1-preview-515e9b9c",
  "@metamask-previews/multichain": "4.0.0-preview-515e9b9c",
  "@metamask-previews/multichain-api-middleware": "0.2.0-preview-515e9b9c",
  "@metamask-previews/multichain-network-controller": "0.6.0-preview-515e9b9c",
  "@metamask-previews/multichain-transactions-controller": "0.10.0-preview-515e9b9c",
  "@metamask-previews/name-controller": "8.0.3-preview-515e9b9c",
  "@metamask-previews/network-controller": "23.4.0-preview-515e9b9c",
  "@metamask-previews/notification-services-controller": "7.0.0-preview-515e9b9c",
  "@metamask-previews/permission-controller": "11.0.6-preview-515e9b9c",
  "@metamask-previews/permission-log-controller": "3.0.3-preview-515e9b9c",
  "@metamask-previews/phishing-controller": "12.5.0-preview-515e9b9c",
  "@metamask-previews/polling-controller": "13.0.0-preview-515e9b9c",
  "@metamask-previews/preferences-controller": "17.0.0-preview-515e9b9c",
  "@metamask-previews/profile-sync-controller": "14.0.0-preview-515e9b9c",
  "@metamask-previews/queued-request-controller": "10.0.0-preview-515e9b9c",
  "@metamask-previews/rate-limit-controller": "6.0.3-preview-515e9b9c",
  "@metamask-previews/remote-feature-flag-controller": "1.6.0-preview-515e9b9c",
  "@metamask-previews/sample-controllers": "0.1.0-preview-515e9b9c",
  "@metamask-previews/selected-network-controller": "22.0.0-preview-515e9b9c",
  "@metamask-previews/signature-controller": "28.0.0-preview-515e9b9c",
  "@metamask-previews/token-search-discovery-controller": "3.1.0-preview-515e9b9c",
  "@metamask-previews/transaction-controller": "55.0.2-preview-515e9b9c",
  "@metamask-previews/user-operation-controller": "34.0.0-preview-515e9b9c"
}
```

</details>

author_association: CONTRIBUTOR
user: login: sahar-fehri
created_at: 2025-05-14T12:51:20Z
body: @metamaskbot publish-preview
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-05-14T12:57:01Z
body: 
Preview builds have been published. [See these instructions](https://github.com/MetaMask/core/blob/main/docs/contributing.md#using-packages-in-other-projects-during-developmenttesting) for more information about preview builds.

<details>

<summary>Expand for full list of packages and versions.</summary>


```
{
  "@metamask-previews/accounts-controller": "29.0.0-preview-f5bd9ba5",
  "@metamask-previews/address-book-controller": "6.0.3-preview-f5bd9ba5",
  "@metamask-previews/announcement-controller": "7.0.3-preview-f5bd9ba5",
  "@metamask-previews/app-metadata-controller": "1.0.0-preview-f5bd9ba5",
  "@metamask-previews/approval-controller": "7.1.3-preview-f5bd9ba5",
  "@metamask-previews/assets-controllers": "63.0.0-preview-f5bd9ba5",
  "@metamask-previews/base-controller": "8.0.1-preview-f5bd9ba5",
  "@metamask-previews/bridge-controller": "25.0.0-preview-f5bd9ba5",
  "@metamask-previews/bridge-status-controller": "21.0.0-preview-f5bd9ba5",
  "@metamask-previews/build-utils": "3.0.3-preview-f5bd9ba5",
  "@metamask-previews/chain-agnostic-permission": "0.6.0-preview-f5bd9ba5",
  "@metamask-previews/composable-controller": "11.0.0-preview-f5bd9ba5",
  "@metamask-previews/controller-utils": "11.8.0-preview-f5bd9ba5",
  "@metamask-previews/delegation-controller": "0.3.0-preview-f5bd9ba5",
  "@metamask-previews/earn-controller": "0.14.0-preview-f5bd9ba5",
  "@metamask-previews/eip1193-permission-middleware": "0.1.0-preview-f5bd9ba5",
  "@metamask-previews/ens-controller": "16.0.0-preview-f5bd9ba5",
  "@metamask-previews/eth-json-rpc-provider": "4.1.8-preview-f5bd9ba5",
  "@metamask-previews/gas-fee-controller": "23.0.0-preview-f5bd9ba5",
  "@metamask-previews/json-rpc-engine": "10.0.3-preview-f5bd9ba5",
  "@metamask-previews/json-rpc-middleware-stream": "8.0.7-preview-f5bd9ba5",
  "@metamask-previews/keyring-controller": "22.0.0-preview-f5bd9ba5",
  "@metamask-previews/logging-controller": "6.0.4-preview-f5bd9ba5",
  "@metamask-previews/message-manager": "12.0.1-preview-f5bd9ba5",
  "@metamask-previews/multichain": "4.0.0-preview-f5bd9ba5",
  "@metamask-previews/multichain-api-middleware": "0.2.0-preview-f5bd9ba5",
  "@metamask-previews/multichain-network-controller": "0.7.0-preview-f5bd9ba5",
  "@metamask-previews/multichain-transactions-controller": "0.11.0-preview-f5bd9ba5",
  "@metamask-previews/name-controller": "8.0.3-preview-f5bd9ba5",
  "@metamask-previews/network-controller": "23.4.0-preview-f5bd9ba5",
  "@metamask-previews/notification-services-controller": "8.0.0-preview-f5bd9ba5",
  "@metamask-previews/permission-controller": "11.0.6-preview-f5bd9ba5",
  "@metamask-previews/permission-log-controller": "3.0.3-preview-f5bd9ba5",
  "@metamask-previews/phishing-controller": "12.5.0-preview-f5bd9ba5",
  "@metamask-previews/polling-controller": "13.0.0-preview-f5bd9ba5",
  "@metamask-previews/preferences-controller": "18.0.0-preview-f5bd9ba5",
  "@metamask-previews/profile-sync-controller": "15.0.0-preview-f5bd9ba5",
  "@metamask-previews/queued-request-controller": "10.0.0-preview-f5bd9ba5",
  "@metamask-previews/rate-limit-controller": "6.0.3-preview-f5bd9ba5",
  "@metamask-previews/remote-feature-flag-controller": "1.6.0-preview-f5bd9ba5",
  "@metamask-previews/sample-controllers": "0.1.0-preview-f5bd9ba5",
  "@metamask-previews/selected-network-controller": "22.0.0-preview-f5bd9ba5",
  "@metamask-previews/signature-controller": "29.0.0-preview-f5bd9ba5",
  "@metamask-previews/token-search-discovery-controller": "3.1.0-preview-f5bd9ba5",
  "@metamask-previews/transaction-controller": "56.0.0-preview-f5bd9ba5",
  "@metamask-previews/user-operation-controller": "35.0.0-preview-f5bd9ba5"
}
```

</details>

author_association: CONTRIBUTOR
user: login: sahar-fehri
created_at: 2025-05-23T13:28:10Z
body: @metamaskbot publish-preview
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-05-23T13:33:28Z
body: 
Preview builds have been published. [See these instructions](https://github.com/MetaMask/core/blob/main/docs/contributing.md#using-packages-in-other-projects-during-developmenttesting) for more information about preview builds.

<details>

<summary>Expand for full list of packages and versions.</summary>


```
{
  "@metamask-previews/accounts-controller": "29.0.0-preview-e91a391a",
  "@metamask-previews/address-book-controller": "6.0.3-preview-e91a391a",
  "@metamask-previews/announcement-controller": "7.0.3-preview-e91a391a",
  "@metamask-previews/app-metadata-controller": "1.0.0-preview-e91a391a",
  "@metamask-previews/approval-controller": "7.1.3-preview-e91a391a",
  "@metamask-previews/assets-controllers": "63.1.0-preview-e91a391a",
  "@metamask-previews/base-controller": "8.0.1-preview-e91a391a",
  "@metamask-previews/bridge-controller": "26.0.0-preview-e91a391a",
  "@metamask-previews/bridge-status-controller": "23.0.0-preview-e91a391a",
  "@metamask-previews/build-utils": "3.0.3-preview-e91a391a",
  "@metamask-previews/chain-agnostic-permission": "0.7.0-preview-e91a391a",
  "@metamask-previews/composable-controller": "11.0.0-preview-e91a391a",
  "@metamask-previews/controller-utils": "11.9.0-preview-e91a391a",
  "@metamask-previews/delegation-controller": "0.3.0-preview-e91a391a",
  "@metamask-previews/earn-controller": "0.14.0-preview-e91a391a",
  "@metamask-previews/eip1193-permission-middleware": "0.1.0-preview-e91a391a",
  "@metamask-previews/ens-controller": "16.0.0-preview-e91a391a",
  "@metamask-previews/eth-json-rpc-provider": "4.1.8-preview-e91a391a",
  "@metamask-previews/gas-fee-controller": "23.0.0-preview-e91a391a",
  "@metamask-previews/json-rpc-engine": "10.0.3-preview-e91a391a",
  "@metamask-previews/json-rpc-middleware-stream": "8.0.7-preview-e91a391a",
  "@metamask-previews/keyring-controller": "22.0.0-preview-e91a391a",
  "@metamask-previews/logging-controller": "6.0.4-preview-e91a391a",
  "@metamask-previews/message-manager": "12.0.1-preview-e91a391a",
  "@metamask-previews/multichain": "4.1.0-preview-e91a391a",
  "@metamask-previews/multichain-api-middleware": "0.3.0-preview-e91a391a",
  "@metamask-previews/multichain-network-controller": "0.7.0-preview-e91a391a",
  "@metamask-previews/multichain-transactions-controller": "1.0.0-preview-e91a391a",
  "@metamask-previews/name-controller": "8.0.3-preview-e91a391a",
  "@metamask-previews/network-controller": "23.5.0-preview-e91a391a",
  "@metamask-previews/notification-services-controller": "8.0.0-preview-e91a391a",
  "@metamask-previews/permission-controller": "11.0.6-preview-e91a391a",
  "@metamask-previews/permission-log-controller": "3.0.3-preview-e91a391a",
  "@metamask-previews/phishing-controller": "12.5.0-preview-e91a391a",
  "@metamask-previews/polling-controller": "13.0.0-preview-e91a391a",
  "@metamask-previews/preferences-controller": "18.0.0-preview-e91a391a",
  "@metamask-previews/profile-sync-controller": "15.0.0-preview-e91a391a",
  "@metamask-previews/queued-request-controller": "10.0.0-preview-e91a391a",
  "@metamask-previews/rate-limit-controller": "6.0.3-preview-e91a391a",
  "@metamask-previews/remote-feature-flag-controller": "1.6.0-preview-e91a391a",
  "@metamask-previews/sample-controllers": "0.1.0-preview-e91a391a",
  "@metamask-previews/selected-network-controller": "22.1.0-preview-e91a391a",
  "@metamask-previews/signature-controller": "29.0.0-preview-e91a391a",
  "@metamask-previews/token-search-discovery-controller": "3.1.0-preview-e91a391a",
  "@metamask-previews/transaction-controller": "56.2.0-preview-e91a391a",
  "@metamask-previews/user-operation-controller": "35.0.0-preview-e91a391a"
}
```

</details>

author_association: CONTRIBUTOR