diff --git a/packages/assets-controllers/src/TokensController.test.ts b/packages/assets-controllers/src/TokensController.test.ts index 2f30a1da7d..7bc7b27027 100644 --- a/packages/assets-controllers/src/TokensController.test.ts +++ b/packages/assets-controllers/src/TokensController.test.ts @@ -26,6 +26,7 @@ import type { NetworkState, } from '@metamask/network-controller'; import { getDefaultNetworkControllerState } from '@metamask/network-controller'; +import type { Hex } from '@metamask/utils'; import type { Patch } from 'immer'; import nock from 'nock'; import { v1 as uuidV1 } from 'uuid'; @@ -4324,6 +4325,73 @@ describe('TokensController', () => { }); }); + describe('when NetworkController has not configured every supported chain', () => { + it('only seeds mUSD on chains currently configured in NetworkController', async () => { + const account = createMockInternalAccount({ + address: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + }); + + await withController( + // Only mainnet is configured in NetworkController + { listAccounts: [account], configuredChainIds: ['0x1'] }, + ({ controller }) => { + expect( + controller.state.allTokens['0x1'][account.address], + ).toContainEqual(MUSD_TOKEN); + expect(controller.state.allTokens['0xe708']).toBeUndefined(); + expect(controller.state.allTokens['0x8f']).toBeUndefined(); + }, + ); + }); + + it('does not seed mUSD when none of the supported chains are configured', async () => { + const account = createMockInternalAccount({ + address: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + }); + + await withController( + { listAccounts: [account], configuredChainIds: [] }, + ({ controller }) => { + expect(controller.state.allTokens).toStrictEqual({}); + }, + ); + }); + + it('does not seed mUSD when NetworkController state lacks networkConfigurationsByChainId', async () => { + const account = createMockInternalAccount({ + address: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + }); + + await withController( + { listAccounts: [account], configuredChainIds: null }, + ({ controller }) => { + expect(controller.state.allTokens).toStrictEqual({}); + }, + ); + }); + + it('does not seed a supported chainId fired via networkAdded when NetworkController has not configured it yet', async () => { + const account = createMockInternalAccount({ + address: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + }); + + await withController( + // Only Linea is configured in NetworkController. + { listAccounts: [account], configuredChainIds: ['0xe708'] }, + ({ controller, triggerNetworkAdded }) => { + expect( + controller.state.allTokens['0xe708'][account.address], + ).toContainEqual(MUSD_TOKEN); + + // Even though `0x1` is in MUSD_SUPPORTED_CHAIN_IDS, seeding + // respects the configured chain set reported by NetworkController. + triggerNetworkAdded('0x1'); + expect(controller.state.allTokens['0x1']).toBeUndefined(); + }, + ); + }); + }); + describe('when NetworkController:stateChange patch adds a supported chain', () => { it('seeds mUSD for all accounts', async () => { const account = createMockInternalAccount({ @@ -4422,6 +4490,16 @@ type WithControllerArgs = >; mocks?: WithControllerMockArgs; listAccounts?: InternalAccount[]; + /** + * The set of chain IDs to report as currently configured in + * `NetworkController` when the `NetworkController:getState` action is + * invoked. Defaults to all mUSD-supported chains so existing tests + * see seeding behave as if every chain is configured. + * + * Pass `null` to simulate `NetworkController:getState` returning a + * state object without a `networkConfigurationsByChainId` property. + */ + configuredChainIds?: Hex[] | null; }, WithControllerCallback, ]; @@ -4448,6 +4526,7 @@ async function withController( mockNetworkClientConfigurationsByNetworkClientId = {}, mocks = {} as WithControllerMockArgs, listAccounts = [], + configuredChainIds = ['0x1', '0xe708', '0x8f'], }, fn, ] = args.length === 2 ? args : [{}, args[0]]; @@ -4485,6 +4564,7 @@ async function withController( actions: [ 'ApprovalController:addRequest', 'NetworkController:getNetworkClientById', + 'NetworkController:getState', 'AccountsController:getAccount', 'AccountsController:getSelectedAccount', 'AccountsController:listAccounts', @@ -4523,6 +4603,37 @@ async function withController( mockListAccounts, ); + const networkControllerStateMock: NetworkState = + configuredChainIds === null + ? // Simulate a NetworkController state object that is missing the + // `networkConfigurationsByChainId` property altogether. + ({ + ...getDefaultNetworkControllerState(), + networkConfigurationsByChainId: + undefined as unknown as NetworkState['networkConfigurationsByChainId'], + } as NetworkState) + : { + ...getDefaultNetworkControllerState(), + networkConfigurationsByChainId: + configuredChainIds.reduce< + NetworkState['networkConfigurationsByChainId'] + >((acc, chainId) => { + acc[chainId] = { + chainId, + blockExplorerUrls: [], + defaultRpcEndpointIndex: 0, + name: `Network ${chainId}`, + nativeCurrency: 'ETH', + rpcEndpoints: [], + } as never; + return acc; + }, {}), + }; + messenger.registerActionHandler( + 'NetworkController:getState', + () => networkControllerStateMock, + ); + const controller = new TokensController({ chainId: ChainId.mainnet, // The tests assume that this is set, but they shouldn't make that diff --git a/packages/assets-controllers/src/TokensController.ts b/packages/assets-controllers/src/TokensController.ts index 2dc86bbef6..33f4b4e9fb 100644 --- a/packages/assets-controllers/src/TokensController.ts +++ b/packages/assets-controllers/src/TokensController.ts @@ -36,6 +36,7 @@ import { abiERC721 } from '@metamask/metamask-eth-abis'; import type { NetworkClientId, NetworkControllerGetNetworkClientByIdAction, + NetworkControllerGetStateAction, NetworkControllerNetworkAddedEvent, NetworkControllerNetworkDidChangeEvent, NetworkControllerStateChangeEvent, @@ -152,6 +153,7 @@ export type TokensControllerActions = export type AllowedActions = | ApprovalControllerAddRequestAction | NetworkControllerGetNetworkClientByIdAction + | NetworkControllerGetStateAction | AccountsControllerGetAccountAction | AccountsControllerGetSelectedAccountAction | AccountsControllerListAccountsAction; @@ -1244,8 +1246,18 @@ export class TokensController extends BaseController< return; } + // Only seed for chains that are actually configured in NetworkController. + // The `NetworkController:networkAdded` subscriber re-runs seeding when a + // supported chain is added later, so this preserves correctness without + // emitting state changes for chainIds downstream subscribers (e.g. + // TokenRatesController) cannot resolve. + const configuredChainIds = this.#getConfiguredMusdChainIds(); + if (configuredChainIds.length === 0) { + return; + } + this.update((state) => { - for (const chainId of MUSD_SUPPORTED_CHAIN_IDS) { + for (const chainId of configuredChainIds) { state.allTokens[chainId] ??= {}; const accountTokens = state.allTokens[chainId][accountAddress] ?? []; const alreadyPresent = accountTokens.some( @@ -1261,6 +1273,36 @@ export class TokensController extends BaseController< }); } + /** + * Returns the subset of `MUSD_SUPPORTED_CHAIN_IDS` that are currently + * configured in `NetworkController`. Returns an empty array if the + * NetworkController state is unavailable. + * + * @returns The subset of `MUSD_SUPPORTED_CHAIN_IDS` that are currently configured in `NetworkController`. + */ + #getConfiguredMusdChainIds(): Hex[] { + let networkConfigurationsByChainId: + | NetworkState['networkConfigurationsByChainId'] + | undefined; + try { + ({ networkConfigurationsByChainId } = this.messenger.call( + 'NetworkController:getState', + )); + } catch { + return []; + } + if (!networkConfigurationsByChainId) { + return []; + } + const configured: Hex[] = []; + for (const chainId of MUSD_SUPPORTED_CHAIN_IDS) { + if (networkConfigurationsByChainId[chainId]) { + configured.push(chainId); + } + } + return configured; + } + /** * Seed mUSD for every existing EVM account via AccountsController. * Called on KeyringController:unlock and on network/account events.