From 7c9be9297ddd52182e89056bae9e711be8e5060f Mon Sep 17 00:00:00 2001 From: salimtb Date: Fri, 3 Jul 2026 23:44:25 +0200 Subject: [PATCH 1/5] fix(assets-controller): seed native balances after account refresh Zero-balancing for new accounts ran before getAssets on account-tree changes, so assetsBalance was often never populated for the new account. Seed native zero balances using the refreshed account list after the fetch. --- packages/assets-controller/CHANGELOG.md | 5 + .../assets-controller/src/AssetsController.ts | 112 +++++++++++++++++- 2 files changed, 112 insertions(+), 5 deletions(-) diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index a6108aa0b1..af8a8e0be5 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Fetch account balances via RPC when switching EVM accounts, enabling RPC-only networks, switching account groups, or after a new account is added to the account tree + - Integrators must delegate `AccountsController:selectedEvmAccountChange` to the AssetsController messenger + ## [10.0.1] ### Changed diff --git a/packages/assets-controller/src/AssetsController.ts b/packages/assets-controller/src/AssetsController.ts index cd16591df8..05d1ffbaf5 100644 --- a/packages/assets-controller/src/AssetsController.ts +++ b/packages/assets-controller/src/AssetsController.ts @@ -3,7 +3,10 @@ import type { AccountTreeControllerSelectedAccountGroupChangeEvent, AccountTreeControllerState, } from '@metamask/account-tree-controller'; -import type { AccountsControllerGetSelectedAccountAction } from '@metamask/accounts-controller'; +import type { + AccountsControllerGetSelectedAccountAction, + AccountsControllerSelectedEvmAccountChangeEvent, +} from '@metamask/accounts-controller'; import { BaseController } from '@metamask/base-controller'; import type { ControllerGetStateAction, @@ -359,6 +362,7 @@ type AllowedEvents = | NetworkEnablementControllerStateChangedEvent // SnapDataSource | AccountsControllerAccountBalancesUpdatedEvent + | AccountsControllerSelectedEvmAccountChangeEvent | PermissionControllerStateChange | SnapControllerSnapInstalledEvent // BackendWebsocketDataSource @@ -1079,6 +1083,14 @@ export class AssetsController extends BaseController< }, ); + // Switching EVM address within the same account group (e.g. Account 1 → Account 2). + this.messenger.subscribe( + 'AccountsController:selectedEvmAccountChange', + (account) => { + this.#handleSelectedEvmAccountChanged(account).catch(console.error); + }, + ); + // Catch the initial tree build. On returning users, // `selectedAccountGroupChange` does NOT fire when the persisted group // is unchanged, and `accountTreeChange` doesn't fire either (init() @@ -1329,10 +1341,14 @@ export class AssetsController extends BaseController< currentCount: currentIds.size, }); + const newAccounts = accounts.filter( + (account) => !this.#lastKnownAccountIds.has(account.id), + ); + this.#lastKnownAccountIds = currentIds; this.#ensureNativeBalancesDefaultZero(); this.#ensureDefaultTrackedAssetsSeeded(); - this.#runAccountTreeRefresh(accounts).catch((error) => { + this.#runAccountTreeRefresh(accounts, newAccounts).catch((error) => { log('Failed to refresh assets after tree change', error); }); } else { @@ -1340,7 +1356,10 @@ export class AssetsController extends BaseController< } } - async #runAccountTreeRefresh(accounts: InternalAccount[]): Promise { + async #runAccountTreeRefresh( + accounts: InternalAccount[], + newAccounts: InternalAccount[] = [], + ): Promise { const releaseLock = await this.#accountRefreshMutex.acquire(); try { await this.getAssets(accounts, { @@ -1348,6 +1367,11 @@ export class AssetsController extends BaseController< forceUpdate: true, }); this.#subscribeAssets(); + if (newAccounts.length > 0) { + await this.#fetchAccountBalancesViaRpc(newAccounts, [ + ...this.#enabledChains, + ]); + } } catch (error) { log('Failed to fetch assets after tree change', error); this.#subscribeAssets(); @@ -1356,6 +1380,42 @@ export class AssetsController extends BaseController< } } + /** + * Fetch balances via RpcDataSource and merge into state. + * + * @param accounts - Accounts to fetch for. + * @param chainIds - Chains to fetch on. + */ + async #fetchAccountBalancesViaRpc( + accounts: InternalAccount[], + chainIds: ChainId[], + ): Promise { + if (accounts.length === 0 || chainIds.length === 0) { + return; + } + + const rpcChains = chainIds.filter((chainId) => + this.#rpcDataSource.getActiveChainsSync().includes(chainId), + ); + if (rpcChains.length === 0) { + return; + } + + const request = this.#buildDataRequest(accounts, rpcChains, { + dataTypes: ['balance'], + forceUpdate: true, + }); + const { response } = await this.#executeMiddlewares( + [ + createParallelBalanceMiddleware([this.#rpcDataSource]), + this.#detectionMiddleware, + ], + request, + ); + + await this.#updateState({ ...response, updateMode: 'merge' }); + } + /** * Force-fetch balances then subscribe. Used on unlock / first startup. * @@ -3320,6 +3380,10 @@ export class AssetsController extends BaseController< // Subscribe after fetch so WS notifications can recover state this.#subscribeAssets(); + await this.#fetchAccountBalancesViaRpc(accounts, [ + ...this.#enabledChains, + ]); + this.#ensureNativeBalancesDefaultZero(); this.#ensureDefaultTrackedAssetsSeeded(); } finally { @@ -3327,6 +3391,39 @@ export class AssetsController extends BaseController< } } + /** + * Handle EVM account selection within the current account group. + * Fires when the user picks a different address under the same logical + * account (not when switching account groups). + * + * @param account - Newly selected EVM account. + */ + async #handleSelectedEvmAccountChanged( + account: InternalAccount, + ): Promise { + if (!this.#uiOpen || !this.#keyringUnlocked || !this.#isEnabled()) { + return; + } + + const releaseLock = await this.#accountRefreshMutex.acquire(); + try { + await this.getAssets([account], { + chainIds: [...this.#enabledChains], + forceUpdate: true, + }); + + this.#subscribeAssets(); + + await this.#fetchAccountBalancesViaRpc([account], [ + ...this.#enabledChains, + ]); + + this.#ensureNativeBalancesDefaultZero(); + } finally { + releaseLock(); + } + } + async #handleEnabledNetworksChanged( enabledNetworkMap: NetworkEnablementControllerState['enabledNetworkMap'], ): Promise { @@ -3364,12 +3461,15 @@ export class AssetsController extends BaseController< this.#subscribeAssets(); // Do one-time fetch for newly enabled chains; merge so we keep existing chain balances - if (addedChains.length > 0 && this.#getSelectedAccounts().length > 0) { - await this.getAssets(this.#getSelectedAccounts(), { + const accounts = this.#getSelectedAccounts(); + if (addedChains.length > 0 && accounts.length > 0) { + await this.getAssets(accounts, { chainIds: addedChains, forceUpdate: true, updateMode: 'merge', }); + + await this.#fetchAccountBalancesViaRpc(accounts, addedChains); } this.#ensureNativeBalancesDefaultZero(); @@ -3487,6 +3587,8 @@ export class AssetsController extends BaseController< dataTypes: ['balance', 'metadata', 'price'], }); + await this.#fetchAccountBalancesViaRpc(accounts, [selectedChainId]); + this.#ensureNativeBalancesDefaultZero(); this.#fetchMissingPricesWithoutCache(accounts, [selectedChainId]); } finally { From 5336a8570010fec048ea37d89f5f4d2a0226a1fa Mon Sep 17 00:00:00 2001 From: salimtb Date: Sat, 4 Jul 2026 13:03:58 +0200 Subject: [PATCH 2/5] fix: clean up --- packages/assets-controller/CHANGELOG.md | 2 +- packages/assets-controller/src/AssetsController.ts | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index af8a8e0be5..27ec29543d 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- Fetch account balances via RPC when switching EVM accounts, enabling RPC-only networks, switching account groups, or after a new account is added to the account tree +- Fetch account balances via RPC when switching EVM accounts, enabling RPC-only networks, switching account groups, or after a new account is added to the account tree ([#9388](https://github.com/MetaMask/core/pull/9388)) - Integrators must delegate `AccountsController:selectedEvmAccountChange` to the AssetsController messenger ## [10.0.1] diff --git a/packages/assets-controller/src/AssetsController.ts b/packages/assets-controller/src/AssetsController.ts index 05d1ffbaf5..ddaa1f147b 100644 --- a/packages/assets-controller/src/AssetsController.ts +++ b/packages/assets-controller/src/AssetsController.ts @@ -3414,9 +3414,10 @@ export class AssetsController extends BaseController< this.#subscribeAssets(); - await this.#fetchAccountBalancesViaRpc([account], [ - ...this.#enabledChains, - ]); + await this.#fetchAccountBalancesViaRpc( + [account], + [...this.#enabledChains], + ); this.#ensureNativeBalancesDefaultZero(); } finally { From 01ed1a1ed7acdce7ba353446b1d1fd5eebcd1e6d Mon Sep 17 00:00:00 2001 From: salimtb Date: Sun, 5 Jul 2026 01:44:21 +0200 Subject: [PATCH 3/5] fix: clean up --- .../assets-controller/src/AssetsController.ts | 39 +++++++++---------- 1 file changed, 18 insertions(+), 21 deletions(-) diff --git a/packages/assets-controller/src/AssetsController.ts b/packages/assets-controller/src/AssetsController.ts index ddaa1f147b..4a0da9c85d 100644 --- a/packages/assets-controller/src/AssetsController.ts +++ b/packages/assets-controller/src/AssetsController.ts @@ -2,6 +2,7 @@ import type { AccountTreeControllerGetAccountsFromSelectedAccountGroupAction, AccountTreeControllerSelectedAccountGroupChangeEvent, AccountTreeControllerState, + AccountTreeControllerStateChangeEvent, } from '@metamask/account-tree-controller'; import type { AccountsControllerGetSelectedAccountAction, @@ -14,7 +15,10 @@ import type { ControllerStateChangedEvent, StateMetadata, } from '@metamask/base-controller'; -import type { ClientControllerState } from '@metamask/client-controller'; +import type { + ClientControllerState, + ClientControllerStateChangeEvent, +} from '@metamask/client-controller'; import { clientControllerSelectors } from '@metamask/client-controller'; import type { TraceCallback } from '@metamask/controller-utils'; import type { @@ -42,8 +46,8 @@ import type { } from '@metamask/network-controller'; import type { NetworkEnablementControllerGetStateAction, - NetworkEnablementControllerEvents, NetworkEnablementControllerState, + NetworkEnablementControllerStateChangeEvent, } from '@metamask/network-enablement-controller'; import type { GetPermissions, @@ -324,26 +328,16 @@ type AllowedActions = // PhishingController | PhishingControllerBulkScanTokensAction; -type AccountTreeControllerStateChangedEvent = ControllerStateChangedEvent< - 'AccountTreeController', - AccountTreeControllerState ->; - -type ClientControllerStateChangedEvent = ControllerStateChangedEvent< - 'ClientController', - ClientControllerState ->; - -type NetworkEnablementControllerStateChangedEvent = ControllerStateChangedEvent< - 'NetworkEnablementController', - NetworkEnablementControllerState ->; - type AllowedEvents = // AssetsController | AccountTreeControllerSelectedAccountGroupChangeEvent - | AccountTreeControllerStateChangedEvent - | ClientControllerStateChangedEvent + | AccountTreeControllerStateChangeEvent + | ControllerStateChangedEvent< + 'AccountTreeController', + AccountTreeControllerState + > + | ClientControllerStateChangeEvent + | ControllerStateChangedEvent<'ClientController', ClientControllerState> | KeyringControllerLockEvent | KeyringControllerUnlockEvent | PreferencesControllerStateChangeEvent @@ -358,8 +352,11 @@ type AllowedEvents = | NetworkControllerNetworkDidChangeEvent | NetworkControllerNetworkRemovedEvent // StakedBalanceDataSource - | NetworkEnablementControllerEvents - | NetworkEnablementControllerStateChangedEvent + | NetworkEnablementControllerStateChangeEvent + | ControllerStateChangedEvent< + 'NetworkEnablementController', + NetworkEnablementControllerState + > // SnapDataSource | AccountsControllerAccountBalancesUpdatedEvent | AccountsControllerSelectedEvmAccountChangeEvent From d0d117fb8f65c0ecbabf92ff325152d8e50435b5 Mon Sep 17 00:00:00 2001 From: salimtb Date: Mon, 6 Jul 2026 11:57:22 +0200 Subject: [PATCH 4/5] fix: fix PR comments --- packages/assets-controller/CHANGELOG.md | 3 +- .../assets-controller/src/AssetsController.ts | 96 +++++-------------- 2 files changed, 27 insertions(+), 72 deletions(-) diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index 27ec29543d..84013053ef 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -9,8 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- Fetch account balances via RPC when switching EVM accounts, enabling RPC-only networks, switching account groups, or after a new account is added to the account tree ([#9388](https://github.com/MetaMask/core/pull/9388)) - - Integrators must delegate `AccountsController:selectedEvmAccountChange` to the AssetsController messenger +- Fetch account balances via RPC when switching account groups, enabling RPC-only networks, or after a new account is added to the account tree ([#9388](https://github.com/MetaMask/core/pull/9388)) ## [10.0.1] diff --git a/packages/assets-controller/src/AssetsController.ts b/packages/assets-controller/src/AssetsController.ts index 4a0da9c85d..54c2e492bf 100644 --- a/packages/assets-controller/src/AssetsController.ts +++ b/packages/assets-controller/src/AssetsController.ts @@ -2,12 +2,8 @@ import type { AccountTreeControllerGetAccountsFromSelectedAccountGroupAction, AccountTreeControllerSelectedAccountGroupChangeEvent, AccountTreeControllerState, - AccountTreeControllerStateChangeEvent, } from '@metamask/account-tree-controller'; -import type { - AccountsControllerGetSelectedAccountAction, - AccountsControllerSelectedEvmAccountChangeEvent, -} from '@metamask/accounts-controller'; +import type { AccountsControllerGetSelectedAccountAction } from '@metamask/accounts-controller'; import { BaseController } from '@metamask/base-controller'; import type { ControllerGetStateAction, @@ -15,10 +11,7 @@ import type { ControllerStateChangedEvent, StateMetadata, } from '@metamask/base-controller'; -import type { - ClientControllerState, - ClientControllerStateChangeEvent, -} from '@metamask/client-controller'; +import type { ClientControllerState } from '@metamask/client-controller'; import { clientControllerSelectors } from '@metamask/client-controller'; import type { TraceCallback } from '@metamask/controller-utils'; import type { @@ -46,8 +39,8 @@ import type { } from '@metamask/network-controller'; import type { NetworkEnablementControllerGetStateAction, + NetworkEnablementControllerEvents, NetworkEnablementControllerState, - NetworkEnablementControllerStateChangeEvent, } from '@metamask/network-enablement-controller'; import type { GetPermissions, @@ -328,16 +321,26 @@ type AllowedActions = // PhishingController | PhishingControllerBulkScanTokensAction; +type AccountTreeControllerStateChangedEvent = ControllerStateChangedEvent< + 'AccountTreeController', + AccountTreeControllerState +>; + +type ClientControllerStateChangedEvent = ControllerStateChangedEvent< + 'ClientController', + ClientControllerState +>; + +type NetworkEnablementControllerStateChangedEvent = ControllerStateChangedEvent< + 'NetworkEnablementController', + NetworkEnablementControllerState +>; + type AllowedEvents = // AssetsController | AccountTreeControllerSelectedAccountGroupChangeEvent - | AccountTreeControllerStateChangeEvent - | ControllerStateChangedEvent< - 'AccountTreeController', - AccountTreeControllerState - > - | ClientControllerStateChangeEvent - | ControllerStateChangedEvent<'ClientController', ClientControllerState> + | AccountTreeControllerStateChangedEvent + | ClientControllerStateChangedEvent | KeyringControllerLockEvent | KeyringControllerUnlockEvent | PreferencesControllerStateChangeEvent @@ -352,14 +355,10 @@ type AllowedEvents = | NetworkControllerNetworkDidChangeEvent | NetworkControllerNetworkRemovedEvent // StakedBalanceDataSource - | NetworkEnablementControllerStateChangeEvent - | ControllerStateChangedEvent< - 'NetworkEnablementController', - NetworkEnablementControllerState - > + | NetworkEnablementControllerEvents + | NetworkEnablementControllerStateChangedEvent // SnapDataSource | AccountsControllerAccountBalancesUpdatedEvent - | AccountsControllerSelectedEvmAccountChangeEvent | PermissionControllerStateChange | SnapControllerSnapInstalledEvent // BackendWebsocketDataSource @@ -1080,14 +1079,6 @@ export class AssetsController extends BaseController< }, ); - // Switching EVM address within the same account group (e.g. Account 1 → Account 2). - this.messenger.subscribe( - 'AccountsController:selectedEvmAccountChange', - (account) => { - this.#handleSelectedEvmAccountChanged(account).catch(console.error); - }, - ); - // Catch the initial tree build. On returning users, // `selectedAccountGroupChange` does NOT fire when the persisted group // is unchanged, and `accountTreeChange` doesn't fire either (init() @@ -1365,9 +1356,10 @@ export class AssetsController extends BaseController< }); this.#subscribeAssets(); if (newAccounts.length > 0) { - await this.#fetchAccountBalancesViaRpc(newAccounts, [ - ...this.#enabledChains, - ]); + await this.getAssets(newAccounts, { + chainIds: [...this.#enabledChains], + forceUpdate: true, + }); } } catch (error) { log('Failed to fetch assets after tree change', error); @@ -3388,40 +3380,6 @@ export class AssetsController extends BaseController< } } - /** - * Handle EVM account selection within the current account group. - * Fires when the user picks a different address under the same logical - * account (not when switching account groups). - * - * @param account - Newly selected EVM account. - */ - async #handleSelectedEvmAccountChanged( - account: InternalAccount, - ): Promise { - if (!this.#uiOpen || !this.#keyringUnlocked || !this.#isEnabled()) { - return; - } - - const releaseLock = await this.#accountRefreshMutex.acquire(); - try { - await this.getAssets([account], { - chainIds: [...this.#enabledChains], - forceUpdate: true, - }); - - this.#subscribeAssets(); - - await this.#fetchAccountBalancesViaRpc( - [account], - [...this.#enabledChains], - ); - - this.#ensureNativeBalancesDefaultZero(); - } finally { - releaseLock(); - } - } - async #handleEnabledNetworksChanged( enabledNetworkMap: NetworkEnablementControllerState['enabledNetworkMap'], ): Promise { @@ -3585,8 +3543,6 @@ export class AssetsController extends BaseController< dataTypes: ['balance', 'metadata', 'price'], }); - await this.#fetchAccountBalancesViaRpc(accounts, [selectedChainId]); - this.#ensureNativeBalancesDefaultZero(); this.#fetchMissingPricesWithoutCache(accounts, [selectedChainId]); } finally { From 8e8793363de057d48dff138524aced4fe1d3f6c1 Mon Sep 17 00:00:00 2001 From: salimtb Date: Mon, 6 Jul 2026 12:08:13 +0200 Subject: [PATCH 5/5] fix: fix PR comments --- packages/assets-controller/CHANGELOG.md | 2 +- .../assets-controller/src/AssetsController.ts | 42 ------------------- 2 files changed, 1 insertion(+), 43 deletions(-) diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index 84013053ef..9c7c1dfee1 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- Fetch account balances via RPC when switching account groups, enabling RPC-only networks, or after a new account is added to the account tree ([#9388](https://github.com/MetaMask/core/pull/9388)) +- Fetch balances when switching account groups, enabling RPC-only networks, or after a new account is added to the account tree ([#9388](https://github.com/MetaMask/core/pull/9388)) ## [10.0.1] diff --git a/packages/assets-controller/src/AssetsController.ts b/packages/assets-controller/src/AssetsController.ts index 54c2e492bf..9e99e94d7c 100644 --- a/packages/assets-controller/src/AssetsController.ts +++ b/packages/assets-controller/src/AssetsController.ts @@ -1369,42 +1369,6 @@ export class AssetsController extends BaseController< } } - /** - * Fetch balances via RpcDataSource and merge into state. - * - * @param accounts - Accounts to fetch for. - * @param chainIds - Chains to fetch on. - */ - async #fetchAccountBalancesViaRpc( - accounts: InternalAccount[], - chainIds: ChainId[], - ): Promise { - if (accounts.length === 0 || chainIds.length === 0) { - return; - } - - const rpcChains = chainIds.filter((chainId) => - this.#rpcDataSource.getActiveChainsSync().includes(chainId), - ); - if (rpcChains.length === 0) { - return; - } - - const request = this.#buildDataRequest(accounts, rpcChains, { - dataTypes: ['balance'], - forceUpdate: true, - }); - const { response } = await this.#executeMiddlewares( - [ - createParallelBalanceMiddleware([this.#rpcDataSource]), - this.#detectionMiddleware, - ], - request, - ); - - await this.#updateState({ ...response, updateMode: 'merge' }); - } - /** * Force-fetch balances then subscribe. Used on unlock / first startup. * @@ -3369,10 +3333,6 @@ export class AssetsController extends BaseController< // Subscribe after fetch so WS notifications can recover state this.#subscribeAssets(); - await this.#fetchAccountBalancesViaRpc(accounts, [ - ...this.#enabledChains, - ]); - this.#ensureNativeBalancesDefaultZero(); this.#ensureDefaultTrackedAssetsSeeded(); } finally { @@ -3424,8 +3384,6 @@ export class AssetsController extends BaseController< forceUpdate: true, updateMode: 'merge', }); - - await this.#fetchAccountBalancesViaRpc(accounts, addedChains); } this.#ensureNativeBalancesDefaultZero();