From ad2c7e232728d360c1839b78609a540c555ad24f Mon Sep 17 00:00:00 2001 From: gantunesr <17601467+gantunesr@users.noreply.github.com> Date: Mon, 20 Jul 2026 12:50:59 -0400 Subject: [PATCH 01/13] feat: add getAccountFromSelectedAccountGroup method --- ...countTreeController-method-action-types.ts | 25 ++ .../src/AccountTreeController.test.ts | 246 ++++++++++++++++++ .../src/AccountTreeController.ts | 76 +++++- packages/account-tree-controller/src/index.ts | 1 + 4 files changed, 335 insertions(+), 13 deletions(-) diff --git a/packages/account-tree-controller/src/AccountTreeController-method-action-types.ts b/packages/account-tree-controller/src/AccountTreeController-method-action-types.ts index abc6ca61e9d..8750542433c 100644 --- a/packages/account-tree-controller/src/AccountTreeController-method-action-types.ts +++ b/packages/account-tree-controller/src/AccountTreeController-method-action-types.ts @@ -65,6 +65,30 @@ export type AccountTreeControllerGetAccountsFromSelectedAccountGroupAction = { handler: AccountTreeController['getAccountsFromSelectedAccountGroup']; }; +/** + * Gets an account from the currently selected account group, optionally + * filtered by a CAIP-2 chain ID. + * + * This is the group-based replacement for both + * `AccountsController:getSelectedAccount` and + * `AccountsController:getSelectedMultichainAccount`. + * + * When no chain ID is provided, an account of the selected group is returned + * using an EVM-priority rule: the first EVM account found in the group, or the + * first account in the group if no EVM account is found. When a chain ID is + * provided, the first account in the selected group whose scopes match the + * given chain is returned. + * + * @param chainId - Optional CAIP-2 chain ID used to filter accounts by scope. + * @returns The matching internal account from the selected group, or + * undefined if no group is selected or no account matches. + * @throws If `chainId` is provided but is not a valid CAIP-2 chain ID. + */ +export type AccountTreeControllerGetAccountFromSelectedAccountGroupAction = { + type: `AccountTreeController:getAccountFromSelectedAccountGroup`; + handler: AccountTreeController['getAccountFromSelectedAccountGroup']; +}; + /** * Gets the account group object from its ID. * @@ -208,6 +232,7 @@ export type AccountTreeControllerMethodActions = | AccountTreeControllerGetAccountWalletObjectAction | AccountTreeControllerGetAccountWalletObjectsAction | AccountTreeControllerGetAccountsFromSelectedAccountGroupAction + | AccountTreeControllerGetAccountFromSelectedAccountGroupAction | AccountTreeControllerGetAccountGroupObjectAction | AccountTreeControllerGetAccountContextAction | AccountTreeControllerGetSelectedAccountGroupAction diff --git a/packages/account-tree-controller/src/AccountTreeController.test.ts b/packages/account-tree-controller/src/AccountTreeController.test.ts index dfdf7bfa5e8..1916481eb95 100644 --- a/packages/account-tree-controller/src/AccountTreeController.test.ts +++ b/packages/account-tree-controller/src/AccountTreeController.test.ts @@ -30,6 +30,7 @@ import type { KeyringObject } from '@metamask/keyring-controller'; import { KeyringTypes } from '@metamask/keyring-controller'; import type { InternalAccount } from '@metamask/keyring-internal-api'; import type { GetSnap as SnapControllerGetSnap } from '@metamask/snaps-controllers'; +import type { CaipChainId } from '@metamask/utils'; import { getAccountTreeControllerMessenger, @@ -1230,6 +1231,212 @@ describe('AccountTreeController', () => { }); }); + describe('getAccountFromSelectedAccountGroup', () => { + const evmAndNonEvmGroupSetup = (): { + controller: AccountTreeController; + mockSolAccount1: Bip44Account; + } => { + const mockSolAccount1: Bip44Account = { + ...MOCK_SNAP_ACCOUNT_1, + options: { + entropy: { + ...MOCK_SNAP_ACCOUNT_1.options.entropy, + groupIndex: 0, + }, + }, + }; + + const { controller } = setup({ + accounts: [MOCK_HD_ACCOUNT_2, mockSolAccount1], + keyrings: [MOCK_HD_KEYRING_2], + }); + + controller.init(); + + return { controller, mockSolAccount1 }; + }; + + it('returns the EVM account when the selected group has both EVM and non-EVM accounts', () => { + const { controller } = evmAndNonEvmGroupSetup(); + + expect( + controller.getAccountFromSelectedAccountGroup(), + ).toStrictEqual(MOCK_HD_ACCOUNT_2); + }); + + it('returns the first account when the selected group has no EVM account', () => { + const nonEvmGroupId = toAccountGroupId( + MOCK_PREPOPULATED_WALLET_ID, + 'tron-group', + ); + const nonEvmGroupState: Partial = { + selectedAccountGroup: nonEvmGroupId, + accountTree: { + wallets: { + [MOCK_PREPOPULATED_WALLET_ID]: { + id: MOCK_PREPOPULATED_WALLET_ID, + type: AccountWalletType.Entropy, + status: 'ready', + groups: { + [nonEvmGroupId]: { + id: nonEvmGroupId, + type: AccountGroupType.MultichainAccount, + accounts: [MOCK_TRX_ACCOUNT_1.id], + metadata: { + name: 'Tron Group', + entropy: { groupIndex: 0 }, + pinned: false, + hidden: false, + lastSelected: 0, + }, + }, + }, + metadata: { + name: 'Wallet 1', + entropy: { id: MOCK_HD_KEYRING_1.metadata.id }, + }, + }, + }, + }, + }; + + const { controller } = setup({ + accounts: [MOCK_TRX_ACCOUNT_1], + keyrings: [], + state: nonEvmGroupState, + }); + + expect( + controller.getAccountFromSelectedAccountGroup(), + ).toStrictEqual(MOCK_TRX_ACCOUNT_1); + }); + + it('returns undefined when no group is selected', () => { + const { controller } = setup({ + accounts: [], + keyrings: [], + }); + + controller.init(); + + expect( + controller.getAccountFromSelectedAccountGroup(), + ).toBeUndefined(); + }); + + it('returns undefined when the selected group is empty', () => { + const emptyGroupId = toAccountGroupId( + MOCK_PREPOPULATED_WALLET_ID, + 'empty-group', + ); + const emptyGroupState: Partial = { + selectedAccountGroup: emptyGroupId, + accountTree: { + wallets: { + [MOCK_PREPOPULATED_WALLET_ID]: { + id: MOCK_PREPOPULATED_WALLET_ID, + type: AccountWalletType.Entropy, + status: 'ready', + groups: { + [emptyGroupId]: { + id: emptyGroupId, + type: AccountGroupType.MultichainAccount, + accounts: [], + metadata: { + name: 'Empty Group', + entropy: { groupIndex: 0 }, + pinned: false, + hidden: false, + lastSelected: 0, + }, + }, + }, + metadata: { + name: 'Wallet 1', + entropy: { id: MOCK_HD_KEYRING_1.metadata.id }, + }, + }, + }, + }, + }; + + const { controller } = setup({ + accounts: [], + keyrings: [], + state: emptyGroupState, + }); + + expect( + controller.getAccountFromSelectedAccountGroup(), + ).toBeUndefined(); + }); + + it('returns the account from persisted state without calling init()', () => { + const { controller } = setup({ + accounts: [MOCK_HD_ACCOUNT_1], + keyrings: [MOCK_HD_KEYRING_1], + state: MOCK_PREPOPULATED_STATE, + }); + + // init() is NOT called — the account must be resolved from persisted state. + expect( + controller.getAccountFromSelectedAccountGroup(), + ).toStrictEqual(MOCK_HD_ACCOUNT_1); + }); + + it('returns the account matching the given CAIP-2 chain ID (non-EVM)', () => { + const { controller, mockSolAccount1 } = evmAndNonEvmGroupSetup(); + + expect( + controller.getAccountFromSelectedAccountGroup(SolScope.Mainnet), + ).toStrictEqual(mockSolAccount1); + }); + + it('returns the account matching the given CAIP-2 chain ID (EVM)', () => { + const { controller } = evmAndNonEvmGroupSetup(); + + expect( + controller.getAccountFromSelectedAccountGroup(EthScope.Mainnet), + ).toStrictEqual(MOCK_HD_ACCOUNT_2); + }); + + it('returns undefined when no group is selected and a chain ID is provided', () => { + const { controller } = setup({ + accounts: [], + keyrings: [], + }); + + controller.init(); + + expect( + controller.getAccountFromSelectedAccountGroup(SolScope.Mainnet), + ).toBeUndefined(); + }); + + it('returns undefined when no account in the selected group matches the scope', () => { + const { controller } = setup({ + accounts: [MOCK_HD_ACCOUNT_1], + keyrings: [MOCK_HD_KEYRING_1], + state: MOCK_PREPOPULATED_STATE, + }); + + // The selected group only contains an EVM account, so a Sol scope matches nothing. + expect( + controller.getAccountFromSelectedAccountGroup(SolScope.Mainnet), + ).toBeUndefined(); + }); + + it('throws when the chain ID is not a valid CAIP-2 chain ID', () => { + const { controller } = evmAndNonEvmGroupSetup(); + + expect(() => + controller.getAccountFromSelectedAccountGroup( + 'not-a-caip-chain-id' as CaipChainId, + ), + ).toThrow('Invalid CAIP-2 chain ID: not-a-caip-chain-id'); + }); + }); + describe('on AccountsController:accountsRemoved', () => { it('removes an account from the tree', () => { // 2 accounts that share the same entropy source (thus, same wallet). @@ -4285,6 +4492,45 @@ describe('AccountTreeController', () => { expect(spy).toHaveBeenCalled(); }); + it('calls getAccountFromSelectedAccountGroup via AccountTreeController:getAccountFromSelectedAccountGroup', () => { + const spy = jest.spyOn( + AccountTreeController.prototype, + 'getAccountFromSelectedAccountGroup', + ); + + const { controller, messenger } = setup({ + accounts: [MOCK_HD_ACCOUNT_1], + keyrings: [MOCK_HD_KEYRING_1], + }); + + controller.init(); + + messenger.call( + 'AccountTreeController:getAccountFromSelectedAccountGroup', + ); + expect(spy).toHaveBeenCalled(); + }); + + it('calls getAccountFromSelectedAccountGroup with a chain ID via the messenger action', () => { + const spy = jest.spyOn( + AccountTreeController.prototype, + 'getAccountFromSelectedAccountGroup', + ); + + const { controller, messenger } = setup({ + accounts: [MOCK_HD_ACCOUNT_1], + keyrings: [MOCK_HD_KEYRING_1], + }); + + controller.init(); + + messenger.call( + 'AccountTreeController:getAccountFromSelectedAccountGroup', + EthScope.Mainnet as CaipChainId, + ); + expect(spy).toHaveBeenCalledWith(EthScope.Mainnet as CaipChainId); + }); + it('gets account context with AccountTreeController:getAccountContext', () => { const spy = jest.spyOn( AccountTreeController.prototype, diff --git a/packages/account-tree-controller/src/AccountTreeController.ts b/packages/account-tree-controller/src/AccountTreeController.ts index e37b2dc1524..7ef3b44973a 100644 --- a/packages/account-tree-controller/src/AccountTreeController.ts +++ b/packages/account-tree-controller/src/AccountTreeController.ts @@ -13,7 +13,8 @@ import { BaseController } from '@metamask/base-controller'; import type { TraceCallback } from '@metamask/controller-utils'; import { isEvmAccountType } from '@metamask/keyring-api'; import type { InternalAccount } from '@metamask/keyring-internal-api'; -import { assert } from '@metamask/utils'; +import { assert, isCaipChainId } from '@metamask/utils'; +import type { CaipChainId } from '@metamask/utils'; import type { BackupAndSyncEmitAnalyticsEventParams } from './backup-and-sync/analytics'; import { @@ -49,6 +50,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'getSelectedAccountGroup', 'setSelectedAccountGroup', 'getAccountsFromSelectedAccountGroup', + 'getAccountFromSelectedAccountGroup', 'getAccountContext', 'setAccountWalletName', 'setAccountGroupName', @@ -858,6 +860,48 @@ export class AccountTreeController extends BaseController< return selector ? select(accounts, selector) : accounts; } + /** + * Gets an account from the currently selected account group, optionally + * filtered by a CAIP-2 chain ID. + * + * This is the group-based replacement for both + * `AccountsController:getSelectedAccount` and + * `AccountsController:getSelectedMultichainAccount`. + * + * When no chain ID is provided, an account of the selected group is returned + * using an EVM-priority rule: the first EVM account found in the group, or the + * first account in the group if no EVM account is found. When a chain ID is + * provided, the first account in the selected group whose scopes match the + * given chain is returned. + * + * @param chainId - Optional CAIP-2 chain ID used to filter accounts by scope. + * @returns The matching internal account from the selected group, or + * undefined if no group is selected or no account matches. + * @throws If `chainId` is provided but is not a valid CAIP-2 chain ID. + */ + getAccountFromSelectedAccountGroup( + chainId?: CaipChainId, + ): InternalAccount | undefined { + const groupId = this.getSelectedAccountGroup(); + if (!groupId) { + return undefined; + } + + if (!chainId) { + return this.#getAccountFromAccountGroupId(groupId); + } + + if (!isCaipChainId(chainId)) { + throw new Error(`Invalid CAIP-2 chain ID: ${String(chainId)}`); + } + + const accounts = this.getAccountsFromSelectedAccountGroup({ + scopes: [chainId], + }); + + return accounts[0]; + } + /** * Gets the account group object from its ID. * @@ -1303,7 +1347,7 @@ export class AccountTreeController extends BaseController< } // Find the first account in this group to select - const accountToSelect = this.#getDefaultAccountFromAccountGroupId(groupId); + const accountToSelect = this.#getAccountFromAccountGroupId(groupId); if (!accountToSelect) { throw new Error('No accounts found in group'); } @@ -1343,10 +1387,10 @@ export class AccountTreeController extends BaseController< // but our handler is idempotent so it won't cause infinite loop this.messenger.call( 'AccountsController:setSelectedAccount', - accountToSelect, + accountToSelect.id, ); - log(`Selected account is now: ${accountToSelect}`); + log(`Selected account is now: ${accountToSelect.id}`); } } @@ -1439,35 +1483,41 @@ export class AccountTreeController extends BaseController< } /** - * Gets the default account for specified group. + * Gets an account for the specified group using the EVM-priority rule. + * + * The account is the first EVM account found in the group, or the first + * account in the group if no EVM account is found. * * @param groupId - The account group ID. - * @returns The first account ID in the group, or undefined if no accounts found. + * @returns The internal account in the group, or undefined if the group does + * not exist or is empty. */ - #getDefaultAccountFromAccountGroupId( + #getAccountFromAccountGroupId( groupId: AccountGroupId, - ): AccountId | undefined { + ): InternalAccount | undefined { const group = this.#getAccountGroup(groupId); if (group) { - let candidate; + let candidateId: AccountId | undefined; for (const id of group.accounts) { const account = this.messenger.call( 'AccountsController:getAccount', id, ); - if (!candidate) { - candidate = id; + if (!candidateId) { + candidateId = id; } if (account && isEvmAccountType(account.type)) { // EVM accounts have a higher priority, so if we find any, we just // use that account! - return account.id; + return account; } } - return candidate; + return candidateId + ? this.messenger.call('AccountsController:getAccount', candidateId) + : undefined; } return undefined; diff --git a/packages/account-tree-controller/src/index.ts b/packages/account-tree-controller/src/index.ts index 28b2f17f645..454cd53568e 100644 --- a/packages/account-tree-controller/src/index.ts +++ b/packages/account-tree-controller/src/index.ts @@ -25,6 +25,7 @@ export type { AccountTreeControllerGetAccountWalletObjectAction, AccountTreeControllerGetAccountWalletObjectsAction, AccountTreeControllerGetAccountsFromSelectedAccountGroupAction, + AccountTreeControllerGetAccountFromSelectedAccountGroupAction, AccountTreeControllerGetAccountGroupObjectAction, AccountTreeControllerGetAccountContextAction, AccountTreeControllerGetSelectedAccountGroupAction, From b82f294893bbb2e80e25a6fe52520f4e6684b2f2 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Fri, 31 Jul 2026 14:16:18 +0200 Subject: [PATCH 02/13] fix: lint issue --- .../src/AccountTreeController.test.ts | 26 ++++++++----------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/packages/account-tree-controller/src/AccountTreeController.test.ts b/packages/account-tree-controller/src/AccountTreeController.test.ts index 1916481eb95..36c608009c3 100644 --- a/packages/account-tree-controller/src/AccountTreeController.test.ts +++ b/packages/account-tree-controller/src/AccountTreeController.test.ts @@ -1259,9 +1259,9 @@ describe('AccountTreeController', () => { it('returns the EVM account when the selected group has both EVM and non-EVM accounts', () => { const { controller } = evmAndNonEvmGroupSetup(); - expect( - controller.getAccountFromSelectedAccountGroup(), - ).toStrictEqual(MOCK_HD_ACCOUNT_2); + expect(controller.getAccountFromSelectedAccountGroup()).toStrictEqual( + MOCK_HD_ACCOUNT_2, + ); }); it('returns the first account when the selected group has no EVM account', () => { @@ -1306,9 +1306,9 @@ describe('AccountTreeController', () => { state: nonEvmGroupState, }); - expect( - controller.getAccountFromSelectedAccountGroup(), - ).toStrictEqual(MOCK_TRX_ACCOUNT_1); + expect(controller.getAccountFromSelectedAccountGroup()).toStrictEqual( + MOCK_TRX_ACCOUNT_1, + ); }); it('returns undefined when no group is selected', () => { @@ -1319,9 +1319,7 @@ describe('AccountTreeController', () => { controller.init(); - expect( - controller.getAccountFromSelectedAccountGroup(), - ).toBeUndefined(); + expect(controller.getAccountFromSelectedAccountGroup()).toBeUndefined(); }); it('returns undefined when the selected group is empty', () => { @@ -1366,9 +1364,7 @@ describe('AccountTreeController', () => { state: emptyGroupState, }); - expect( - controller.getAccountFromSelectedAccountGroup(), - ).toBeUndefined(); + expect(controller.getAccountFromSelectedAccountGroup()).toBeUndefined(); }); it('returns the account from persisted state without calling init()', () => { @@ -1379,9 +1375,9 @@ describe('AccountTreeController', () => { }); // init() is NOT called — the account must be resolved from persisted state. - expect( - controller.getAccountFromSelectedAccountGroup(), - ).toStrictEqual(MOCK_HD_ACCOUNT_1); + expect(controller.getAccountFromSelectedAccountGroup()).toStrictEqual( + MOCK_HD_ACCOUNT_1, + ); }); it('returns the account matching the given CAIP-2 chain ID (non-EVM)', () => { From 4f4b247c68d70b8df990e5f986271612811e40fe Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Fri, 31 Jul 2026 15:16:29 +0200 Subject: [PATCH 03/13] refactor(account-tree-controller): use nullish assignment --- .../account-tree-controller/src/AccountTreeController.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/account-tree-controller/src/AccountTreeController.ts b/packages/account-tree-controller/src/AccountTreeController.ts index b231269790b..f9e059c3fe8 100644 --- a/packages/account-tree-controller/src/AccountTreeController.ts +++ b/packages/account-tree-controller/src/AccountTreeController.ts @@ -1505,9 +1505,8 @@ export class AccountTreeController extends BaseController< id, ); - if (!candidateId) { - candidateId = id; - } + candidateId ??= id; + if (account && isEvmAccountType(account.type)) { // EVM accounts have a higher priority, so if we find any, we just // use that account! From adde756ea98440fcd51d20afe3d3012df1877d4b Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Fri, 31 Jul 2026 15:31:52 +0200 Subject: [PATCH 04/13] feat(account-tree-controller): add setSelectedAccountGroupByAccoundId --- .../account-tree-controller/src/AccountTreeController.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/account-tree-controller/src/AccountTreeController.ts b/packages/account-tree-controller/src/AccountTreeController.ts index f9e059c3fe8..b1b12f7d3a3 100644 --- a/packages/account-tree-controller/src/AccountTreeController.ts +++ b/packages/account-tree-controller/src/AccountTreeController.ts @@ -49,6 +49,7 @@ export const controllerName = 'AccountTreeController'; const MESSENGER_EXPOSED_METHODS = [ 'getSelectedAccountGroup', 'setSelectedAccountGroup', + 'setSelectedAccountGroupByAccountId', 'getAccountsFromSelectedAccountGroup', 'getAccountFromSelectedAccountGroup', 'getAccountContext', @@ -1403,6 +1404,14 @@ export class AccountTreeController extends BaseController< this.#setSelectedAccountGroup(groupId); } + setSelectedAccountGroupByAccountId(accountId: AccountId): void { + const context = this.#accountIdToContext.get(accountId); + if (!context) { + throw new Error('Account not found in the account tree'); + } + this.setSelectedAccountGroup(context.groupId); + } + /** * Initializes the selectedAccountGroup based on the currently selected account from AccountsController. * From e62b58695db3effebaa4274c468b50f8c65d90fb Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Fri, 31 Jul 2026 15:32:16 +0200 Subject: [PATCH 05/13] chore(account-tree-controller): regenerate method action types --- .../src/AccountTreeController-method-action-types.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/account-tree-controller/src/AccountTreeController-method-action-types.ts b/packages/account-tree-controller/src/AccountTreeController-method-action-types.ts index 08ca1a031eb..cd8a46fe9f5 100644 --- a/packages/account-tree-controller/src/AccountTreeController-method-action-types.ts +++ b/packages/account-tree-controller/src/AccountTreeController-method-action-types.ts @@ -131,6 +131,11 @@ export type AccountTreeControllerSetSelectedAccountGroupAction = { handler: AccountTreeController['setSelectedAccountGroup']; }; +export type AccountTreeControllerSetSelectedAccountGroupByAccountIdAction = { + type: `AccountTreeController:setSelectedAccountGroupByAccountId`; + handler: AccountTreeController['setSelectedAccountGroupByAccountId']; +}; + /** * Sets a custom name for an account group. * @@ -237,6 +242,7 @@ export type AccountTreeControllerMethodActions = | AccountTreeControllerGetAccountContextAction | AccountTreeControllerGetSelectedAccountGroupAction | AccountTreeControllerSetSelectedAccountGroupAction + | AccountTreeControllerSetSelectedAccountGroupByAccountIdAction | AccountTreeControllerSetAccountGroupNameAction | AccountTreeControllerSetAccountWalletNameAction | AccountTreeControllerSetAccountGroupPinnedAction From a3f6a7125b44fda2e0de5277dde4a2c314744c98 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Fri, 31 Jul 2026 15:37:51 +0200 Subject: [PATCH 06/13] test(account-tree-controller): add tests for setSelectedAccountGroupByAccoundId --- .../src/AccountTreeController.test.ts | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/packages/account-tree-controller/src/AccountTreeController.test.ts b/packages/account-tree-controller/src/AccountTreeController.test.ts index 9f91689d60d..8ec81ea7d95 100644 --- a/packages/account-tree-controller/src/AccountTreeController.test.ts +++ b/packages/account-tree-controller/src/AccountTreeController.test.ts @@ -2815,6 +2815,76 @@ describe('AccountTreeController', () => { }); }); + describe('setSelectedAccountGroupByAccountId', () => { + it('sets the selected account group for a known account', () => { + const { controller } = setup({ + accounts: [MOCK_HD_ACCOUNT_1, MOCK_HD_ACCOUNT_2], + keyrings: [MOCK_HD_KEYRING_1, MOCK_HD_KEYRING_2], + }); + + controller.init(); + + const expectedWalletId2 = toMultichainAccountWalletId( + MOCK_HD_KEYRING_2.metadata.id, + ); + const expectedGroupId2 = toMultichainAccountGroupId( + expectedWalletId2, + MOCK_HD_ACCOUNT_2.options.entropy.groupIndex, + ); + + controller.setSelectedAccountGroupByAccountId(MOCK_HD_ACCOUNT_2.id); + + expect(controller.getSelectedAccountGroup()).toBe(expectedGroupId2); + }); + + it('updates AccountsController selected account when called', () => { + const { controller, accountTreeControllerMessenger } = setup({ + accounts: [MOCK_HD_ACCOUNT_1, MOCK_HD_ACCOUNT_2], + keyrings: [MOCK_HD_KEYRING_1, MOCK_HD_KEYRING_2], + }); + + controller.init(); + + const setSelectedAccountSpy = jest.spyOn( + accountTreeControllerMessenger, + 'call', + ); + + controller.setSelectedAccountGroupByAccountId(MOCK_HD_ACCOUNT_2.id); + + expect(setSelectedAccountSpy).toHaveBeenCalledWith( + 'AccountsController:setSelectedAccount', + expect.any(String), + ); + }); + + it('throws when account ID is not in the tree', () => { + const { controller } = setup({ + accounts: [MOCK_HD_ACCOUNT_1], + keyrings: [MOCK_HD_KEYRING_1], + }); + + controller.init(); + + expect(() => { + controller.setSelectedAccountGroupByAccountId( + 'non-existent-account-id' as AccountId, + ); + }).toThrow('Account not found in the account tree'); + }); + + it('throws before init when account ID is not yet in the tree', () => { + const { controller } = setup({ + accounts: [MOCK_HD_ACCOUNT_1], + keyrings: [MOCK_HD_KEYRING_1], + }); + + expect(() => { + controller.setSelectedAccountGroupByAccountId(MOCK_HD_ACCOUNT_1.id); + }).toThrow('Account not found in the account tree'); + }); + }); + describe('account removal and memory management', () => { it('cleans up reverse mapping and does not change selectedAccountGroup when removing from non-selected group', () => { const { controller, messenger } = setup({ From 116255256bfa3deb35aa5511b59137480bf07469 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Fri, 31 Jul 2026 15:44:50 +0200 Subject: [PATCH 07/13] chore(account-tree-controller): add changelog entry --- packages/account-tree-controller/CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/account-tree-controller/CHANGELOG.md b/packages/account-tree-controller/CHANGELOG.md index 4880b05e01a..c17b4f1fac8 100644 --- a/packages/account-tree-controller/CHANGELOG.md +++ b/packages/account-tree-controller/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `getAccountFromSelectedAccountGroup` and `setSelectedAccountGroupByAccountId` methods ([#9739](https://github.com/MetaMask/core/pull/9739)) + ### Changed - Bump `@metamask/keyring-api` from `^23.5.0` to `^23.7.0` ([#9676](https://github.com/MetaMask/core/pull/9676)) From 0dee89faadefd37e967c52c35c874a7397ae57fa Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Fri, 31 Jul 2026 16:12:57 +0200 Subject: [PATCH 08/13] chore: update eslint suppressions --- eslint-suppressions.json | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 6e9023635e6..45661708cd0 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -14,9 +14,6 @@ "@typescript-eslint/explicit-function-return-type": { "count": 7 }, - "@typescript-eslint/prefer-nullish-coalescing": { - "count": 1 - }, "no-negated-condition": { "count": 3 }, @@ -2354,4 +2351,4 @@ "count": 10 } } -} +} \ No newline at end of file From 208ae82735c099d25b88bc386d1acc9a8eb51b76 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Fri, 31 Jul 2026 16:19:21 +0200 Subject: [PATCH 09/13] feat(account-tree-controller): add action type to export --- packages/account-tree-controller/src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/account-tree-controller/src/index.ts b/packages/account-tree-controller/src/index.ts index 2dce43b264b..bbc654628f4 100644 --- a/packages/account-tree-controller/src/index.ts +++ b/packages/account-tree-controller/src/index.ts @@ -30,6 +30,7 @@ export type { AccountTreeControllerGetAccountContextAction, AccountTreeControllerGetSelectedAccountGroupAction, AccountTreeControllerSetSelectedAccountGroupAction, + AccountTreeControllerSetSelectedAccountGroupByAccountIdAction, AccountTreeControllerSetAccountGroupNameAction, AccountTreeControllerSetAccountWalletNameAction, AccountTreeControllerSetAccountGroupPinnedAction, From e600c6b83aa09fff1f4901e83d9091fe8236e16c Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Fri, 31 Jul 2026 16:22:55 +0200 Subject: [PATCH 10/13] fix: apply code review --- .../src/AccountTreeController.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/account-tree-controller/src/AccountTreeController.ts b/packages/account-tree-controller/src/AccountTreeController.ts index b1b12f7d3a3..0dd2c0988dd 100644 --- a/packages/account-tree-controller/src/AccountTreeController.ts +++ b/packages/account-tree-controller/src/AccountTreeController.ts @@ -1514,12 +1514,14 @@ export class AccountTreeController extends BaseController< id, ); - candidateId ??= id; + if (account) { + candidateId ??= id; - if (account && isEvmAccountType(account.type)) { - // EVM accounts have a higher priority, so if we find any, we just - // use that account! - return account; + if (isEvmAccountType(account.type)) { + // EVM accounts have a higher priority, so if we find any, we just + // use that account! + return account; + } } } From 5c90eb0d067b8b4af3401085b29f9da2db2490ef Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Fri, 31 Jul 2026 16:27:55 +0200 Subject: [PATCH 11/13] fix: lint fix --- eslint-suppressions.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 2fb8406ce13..ef11872af77 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -2351,4 +2351,4 @@ "count": 10 } } -} \ No newline at end of file +} From 7a1eb353d0ed315c2f1f7a02822204a7dc5a5e3c Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Fri, 31 Jul 2026 16:30:42 +0200 Subject: [PATCH 12/13] chore(account-tree-controller): add jsdocs --- .../account-tree-controller/src/AccountTreeController.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/account-tree-controller/src/AccountTreeController.ts b/packages/account-tree-controller/src/AccountTreeController.ts index 0dd2c0988dd..f2460bb3fc5 100644 --- a/packages/account-tree-controller/src/AccountTreeController.ts +++ b/packages/account-tree-controller/src/AccountTreeController.ts @@ -1404,6 +1404,11 @@ export class AccountTreeController extends BaseController< this.#setSelectedAccountGroup(groupId); } + /** + * Sets the selected account group and updates the AccountsController selectedAccount accordingly. + * + * @param accountId - The account ID to select the group for. + */ setSelectedAccountGroupByAccountId(accountId: AccountId): void { const context = this.#accountIdToContext.get(accountId); if (!context) { From 399b7538772fc2a1c58737d28b964397b54057ac Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Fri, 31 Jul 2026 16:42:41 +0200 Subject: [PATCH 13/13] chore(account-tree-controller): update action types --- .../src/AccountTreeController-method-action-types.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/account-tree-controller/src/AccountTreeController-method-action-types.ts b/packages/account-tree-controller/src/AccountTreeController-method-action-types.ts index cd8a46fe9f5..be1ee052f3f 100644 --- a/packages/account-tree-controller/src/AccountTreeController-method-action-types.ts +++ b/packages/account-tree-controller/src/AccountTreeController-method-action-types.ts @@ -131,6 +131,11 @@ export type AccountTreeControllerSetSelectedAccountGroupAction = { handler: AccountTreeController['setSelectedAccountGroup']; }; +/** + * Sets the selected account group and updates the AccountsController selectedAccount accordingly. + * + * @param accountId - The account ID to select the group for. + */ export type AccountTreeControllerSetSelectedAccountGroupByAccountIdAction = { type: `AccountTreeController:setSelectedAccountGroupByAccountId`; handler: AccountTreeController['setSelectedAccountGroupByAccountId'];