diff --git a/packages/account-tree-controller/CHANGELOG.md b/packages/account-tree-controller/CHANGELOG.md index b9ba194bbbd..ede359028ce 100644 --- a/packages/account-tree-controller/CHANGELOG.md +++ b/packages/account-tree-controller/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `lastSelected` (timestamp) to account group tree node metadata ([#8261](https://github.com/MetaMask/core/pull/8261)) + - `group.metadata.lastSelected` is set to `Date.now()` whenever a group becomes the selected group, either via `setSelectedAccountGroup` or `AccountsController:selectedAccountChange`. + - The value is persisted in `accountGroupsMetadata` and restored on `init`/`reinit`. + - The value is not synchronize through backup and sync. + - Now consider `lastSelected` too when the controller needs to use the "default account group ID". + ### Changed - **BREAKING**: Move `selectedAccountGroup` to top-level persisted state to prevent selected account group from reverting after app restart ([#8245](https://github.com/MetaMask/core/pull/8245)) diff --git a/packages/account-tree-controller/src/AccountTreeController.test.ts b/packages/account-tree-controller/src/AccountTreeController.test.ts index f84701ed175..fcd241f05e6 100644 --- a/packages/account-tree-controller/src/AccountTreeController.test.ts +++ b/packages/account-tree-controller/src/AccountTreeController.test.ts @@ -530,6 +530,7 @@ describe('AccountTreeController', () => { }, pinned: false, hidden: false, + lastSelected: 0, }, }, }, @@ -556,6 +557,7 @@ describe('AccountTreeController', () => { }, pinned: false, hidden: false, + lastSelected: 0, }, }, [expectedWalletId2Group2]: { @@ -570,6 +572,7 @@ describe('AccountTreeController', () => { }, pinned: false, hidden: false, + lastSelected: 0, }, }, }, @@ -593,6 +596,7 @@ describe('AccountTreeController', () => { name: 'Snap Account 1', // Updated: per-wallet numbering (different wallet) pinned: false, hidden: false, + lastSelected: 0, }, }, }, @@ -616,6 +620,7 @@ describe('AccountTreeController', () => { name: 'Ledger Account 1', // Updated: per-wallet numbering (different wallet) pinned: false, hidden: false, + lastSelected: 0, }, }, }, @@ -648,6 +653,7 @@ describe('AccountTreeController', () => { value: false, lastUpdatedAt: 0, }, + lastSelected: 0, }, [expectedWalletId2Group1]: { name: { @@ -662,6 +668,7 @@ describe('AccountTreeController', () => { value: false, lastUpdatedAt: 0, }, + lastSelected: 0, }, [expectedWalletId2Group2]: { name: { @@ -676,6 +683,7 @@ describe('AccountTreeController', () => { value: false, lastUpdatedAt: 0, }, + lastSelected: 0, }, [expectedKeyringWalletIdGroup]: { name: { @@ -690,6 +698,7 @@ describe('AccountTreeController', () => { value: false, lastUpdatedAt: 0, }, + lastSelected: 0, }, [expectedSnapWalletIdGroup]: { name: { @@ -704,6 +713,7 @@ describe('AccountTreeController', () => { value: false, lastUpdatedAt: 0, }, + lastSelected: 0, }, }, accountWalletsMetadata: {}, @@ -1199,6 +1209,7 @@ describe('AccountTreeController', () => { }, pinned: false, hidden: false, + lastSelected: 0, }, accounts: [mockHdAccount2.id], // HD account 1 got removed. }, @@ -1230,6 +1241,7 @@ describe('AccountTreeController', () => { value: false, lastUpdatedAt: 0, }, + lastSelected: 0, }, }, accountWalletsMetadata: {}, @@ -1288,6 +1300,7 @@ describe('AccountTreeController', () => { }, pinned: false, hidden: false, + lastSelected: 0, }, accounts: [mockHdAccount2.id], }, @@ -1319,6 +1332,7 @@ describe('AccountTreeController', () => { value: false, lastUpdatedAt: 0, }, + lastSelected: 0, }, }, accountWalletsMetadata: {}, @@ -1521,6 +1535,7 @@ describe('AccountTreeController', () => { }, pinned: false, hidden: false, + lastSelected: 0, }, accounts: [mockHdAccount1.id, mockHdAccount2.id], // HD account 2 got added. }, @@ -1550,6 +1565,7 @@ describe('AccountTreeController', () => { value: false, lastUpdatedAt: 0, }, + lastSelected: 0, }, }, accountWalletsMetadata: {}, @@ -1627,6 +1643,7 @@ describe('AccountTreeController', () => { }, pinned: false, hidden: false, + lastSelected: 0, }, accounts: [mockHdAccount1.id], }, @@ -1654,6 +1671,7 @@ describe('AccountTreeController', () => { }, pinned: false, hidden: false, + lastSelected: 0, }, accounts: [mockHdAccount2.id], }, @@ -1683,6 +1701,7 @@ describe('AccountTreeController', () => { value: false, lastUpdatedAt: 0, }, + lastSelected: 0, }, [walletId2Group]: { name: { @@ -1697,6 +1716,7 @@ describe('AccountTreeController', () => { value: false, lastUpdatedAt: 0, }, + lastSelected: 0, }, }, accountWalletsMetadata: {}, @@ -2084,6 +2104,428 @@ describe('AccountTreeController', () => { // Should return empty string when no wallets exist expect(controller.getSelectedAccountGroup()).toBe(''); }); + + it('sets lastSelected timestamp on group when setSelectedAccountGroup is called', () => { + const { controller } = setup({ + accounts: [MOCK_HD_ACCOUNT_1, MOCK_HD_ACCOUNT_2], + keyrings: [MOCK_HD_KEYRING_1, MOCK_HD_KEYRING_2], + }); + + controller.init(); + + const walletId2 = toMultichainAccountWalletId( + MOCK_HD_KEYRING_2.metadata.id, + ); + const groupId2 = toMultichainAccountGroupId( + walletId2, + MOCK_HD_ACCOUNT_2.options.entropy.groupIndex, + ); + + const beforeTimestamp = Date.now(); + controller.setSelectedAccountGroup(groupId2); + const afterTimestamp = Date.now(); + + const group = + controller.state.accountTree.wallets[walletId2].groups[groupId2]; + expect(group.metadata.lastSelected).toBeGreaterThanOrEqual( + beforeTimestamp, + ); + expect(group.metadata.lastSelected).toBeLessThanOrEqual(afterTimestamp); + + expect( + controller.state.accountGroupsMetadata[groupId2].lastSelected, + ).toBeGreaterThanOrEqual(beforeTimestamp); + expect( + controller.state.accountGroupsMetadata[groupId2].lastSelected, + ).toBeLessThanOrEqual(afterTimestamp); + }); + + it('sets lastSelected timestamp on group when selectedAccountChange event fires', () => { + const { controller, messenger } = setup({ + accounts: [MOCK_HD_ACCOUNT_1, MOCK_HD_ACCOUNT_2], + keyrings: [MOCK_HD_KEYRING_1, MOCK_HD_KEYRING_2], + }); + + controller.init(); + + const walletId2 = toMultichainAccountWalletId( + MOCK_HD_KEYRING_2.metadata.id, + ); + const groupId2 = toMultichainAccountGroupId( + walletId2, + MOCK_HD_ACCOUNT_2.options.entropy.groupIndex, + ); + + const beforeTimestamp = Date.now(); + messenger.publish( + 'AccountsController:selectedAccountChange', + MOCK_HD_ACCOUNT_2, + ); + const afterTimestamp = Date.now(); + + const group = + controller.state.accountTree.wallets[walletId2].groups[groupId2]; + expect(group.metadata.lastSelected).toBeGreaterThanOrEqual( + beforeTimestamp, + ); + expect(group.metadata.lastSelected).toBeLessThanOrEqual(afterTimestamp); + + expect( + controller.state.accountGroupsMetadata[groupId2].lastSelected, + ).toBeGreaterThanOrEqual(beforeTimestamp); + expect( + controller.state.accountGroupsMetadata[groupId2].lastSelected, + ).toBeLessThanOrEqual(afterTimestamp); + }); + + it('restores lastSelected from persisted state on init', () => { + const walletId1 = toMultichainAccountWalletId( + MOCK_HD_KEYRING_1.metadata.id, + ); + const groupId1 = toMultichainAccountGroupId( + walletId1, + MOCK_HD_ACCOUNT_1.options.entropy.groupIndex, + ); + + const persistedTimestamp = 1234567890; + + const { controller } = setup({ + accounts: [MOCK_HD_ACCOUNT_1], + keyrings: [MOCK_HD_KEYRING_1], + state: { + accountGroupsMetadata: { + [groupId1]: { + lastSelected: persistedTimestamp, + }, + }, + }, + }); + + controller.init(); + + const group = + controller.state.accountTree.wallets[walletId1].groups[groupId1]; + expect(group.metadata.lastSelected).toBe(persistedTimestamp); + expect( + controller.state.accountGroupsMetadata[groupId1].lastSelected, + ).toBe(persistedTimestamp); + }); + + it('defaults lastSelected to 0 when no persisted value exists on init', () => { + const { controller } = setup({ + accounts: [MOCK_HD_ACCOUNT_1], + keyrings: [MOCK_HD_KEYRING_1], + }); + + controller.init(); + + const walletId1 = toMultichainAccountWalletId( + MOCK_HD_KEYRING_1.metadata.id, + ); + const groupId1 = toMultichainAccountGroupId( + walletId1, + MOCK_HD_ACCOUNT_1.options.entropy.groupIndex, + ); + + const group = + controller.state.accountTree.wallets[walletId1].groups[groupId1]; + expect(group.metadata.lastSelected).toBe(0); + expect( + controller.state.accountGroupsMetadata[groupId1].lastSelected, + ).toBe(0); + }); + + it('getDefaultAccountGroupId returns group with highest lastSelected when selected group is removed', () => { + const { controller, messenger } = setup({ + accounts: [MOCK_HD_ACCOUNT_1, MOCK_HD_ACCOUNT_2], + keyrings: [MOCK_HD_KEYRING_1, MOCK_HD_KEYRING_2], + }); + + controller.init(); + + const walletId1 = toMultichainAccountWalletId( + MOCK_HD_KEYRING_1.metadata.id, + ); + const groupId1 = toMultichainAccountGroupId( + walletId1, + MOCK_HD_ACCOUNT_1.options.entropy.groupIndex, + ); + const walletId2 = toMultichainAccountWalletId( + MOCK_HD_KEYRING_2.metadata.id, + ); + const groupId2 = toMultichainAccountGroupId( + walletId2, + MOCK_HD_ACCOUNT_2.options.entropy.groupIndex, + ); + + // Select group2 last so it has the highest lastSelected timestamp + controller.setSelectedAccountGroup(groupId1); + controller.setSelectedAccountGroup(groupId2); + + // Remove the account from group2 (currently selected) + messenger.publish('AccountsController:accountsRemoved', [ + MOCK_HD_ACCOUNT_2.id, + ]); + + // Should fall back to group1 (the next most recently selected) + expect(controller.getSelectedAccountGroup()).toBe(groupId1); + }); + + it('getDefaultAccountGroupId skips groups with lower lastSelected than the current candidate', () => { + // This test covers the false branch of: !candidate || lastSelected > candidate.lastSelected + // We need 3 groups where: group3 (currently selected) is removed, + // group1 (first in iteration) has HIGHER lastSelected than group2 (second in iteration). + const thirdKeyringId = 'mock-keyring-id-3'; + const account3: Bip44Account = { + ...MOCK_HD_ACCOUNT_1, + id: 'mock-id-3', + address: '0xDEF', + options: { + ...MOCK_HD_ACCOUNT_1.options, + entropy: { + ...MOCK_HD_ACCOUNT_1.options.entropy, + id: thirdKeyringId, + groupIndex: 0, + }, + }, + metadata: { ...MOCK_HD_ACCOUNT_1.metadata, importTime: 2 }, + }; + + const { controller, messenger } = setup({ + accounts: [MOCK_HD_ACCOUNT_1, MOCK_HD_ACCOUNT_2, account3], + keyrings: [ + MOCK_HD_KEYRING_1, + MOCK_HD_KEYRING_2, + { + type: KeyringTypes.hd, + metadata: { id: thirdKeyringId, name: 'HD Keyring 3' }, + accounts: ['0xDEF'], + }, + ], + }); + + controller.init(); + + const walletId1 = toMultichainAccountWalletId( + MOCK_HD_KEYRING_1.metadata.id, + ); + const groupId1 = toMultichainAccountGroupId( + walletId1, + MOCK_HD_ACCOUNT_1.options.entropy.groupIndex, + ); + const walletId2 = toMultichainAccountWalletId( + MOCK_HD_KEYRING_2.metadata.id, + ); + const groupId2 = toMultichainAccountGroupId( + walletId2, + MOCK_HD_ACCOUNT_2.options.entropy.groupIndex, + ); + const walletId3 = toMultichainAccountWalletId(thirdKeyringId); + const groupId3 = toMultichainAccountGroupId( + walletId3, + account3.options.entropy.groupIndex, + ); + + // Select G2 first (lower timestamp), then G1 (higher timestamp), then G3 (currently selected) + // Iteration order for getDefaultAccountGroupId: G1, G2, G3(empty) + // After removing G3's account: + // G1: !candidate=true → branch A (first non-empty), candidate=G1 + // G2: !candidate=false, T2 T2 (higher) + controller.setSelectedAccountGroup(groupId3); // T3 (currently selected) + + messenger.publish('AccountsController:accountsRemoved', [account3.id]); + + // G1 has higher lastSelected than G2, so G1 wins + expect(controller.getSelectedAccountGroup()).toBe(groupId1); + }); + + it('getDefaultAccountGroupId selects group with strictly higher lastSelected over first group', () => { + // This test covers the true branch of: lastSelected > candidate.lastSelected + // We need 3 groups where: G3 (selected) is removed, + // G1 (first in iteration) has LOWER lastSelected than G2 (second in iteration). + const keyringId3 = 'mock-keyring-id-3-higher'; + const account3: Bip44Account = { + ...MOCK_HD_ACCOUNT_1, + id: 'mock-id-3-higher', + address: '0xGHI', + options: { + ...MOCK_HD_ACCOUNT_1.options, + entropy: { + ...MOCK_HD_ACCOUNT_1.options.entropy, + id: keyringId3, + groupIndex: 0, + }, + }, + metadata: { ...MOCK_HD_ACCOUNT_1.metadata, importTime: 2 }, + }; + + const { controller, messenger } = setup({ + accounts: [MOCK_HD_ACCOUNT_1, MOCK_HD_ACCOUNT_2, account3], + keyrings: [ + MOCK_HD_KEYRING_1, + MOCK_HD_KEYRING_2, + { + type: KeyringTypes.hd, + metadata: { id: keyringId3, name: 'HD Keyring 3 Higher' }, + accounts: ['0xGHI'], + }, + ], + }); + + controller.init(); + + const walletId1 = toMultichainAccountWalletId( + MOCK_HD_KEYRING_1.metadata.id, + ); + const groupId1 = toMultichainAccountGroupId( + walletId1, + MOCK_HD_ACCOUNT_1.options.entropy.groupIndex, + ); + const walletId2 = toMultichainAccountWalletId( + MOCK_HD_KEYRING_2.metadata.id, + ); + const groupId2 = toMultichainAccountGroupId( + walletId2, + MOCK_HD_ACCOUNT_2.options.entropy.groupIndex, + ); + const walletId3 = toMultichainAccountWalletId(keyringId3); + const groupId3 = toMultichainAccountGroupId( + walletId3, + account3.options.entropy.groupIndex, + ); + + // Select G1 first (low ts), G2 second (higher ts), G3 last (currently selected) + // Iteration order after G3 removal: G1, G2, G3(empty) + // G1: !candidate=true → candidate=G1 + // G2: T2 > T1 → branch true (line 1365) → candidate=G2 + // G3: accounts.length=0 → skip + controller.setSelectedAccountGroup(groupId1); // T1 (lowest) + controller.setSelectedAccountGroup(groupId2); // T2 > T1 + controller.setSelectedAccountGroup(groupId3); // T3 (currently selected) + + messenger.publish('AccountsController:accountsRemoved', [account3.id]); + + // G2 has strictly higher lastSelected than G1, so G2 wins + expect(controller.getSelectedAccountGroup()).toBe(groupId2); + }); + + it('getDefaultAccountGroupId prefers EVM group over non-EVM group when lastSelected timestamps are equal', () => { + // This test covers: + // - The EVM tiebreaker branch (else if with equal timestamps) + // - #groupHasEvmAccount returning false for non-EVM group + const trxKeyringId = 'mock-trx-keyring-tiebreaker'; + const evmKeyringId = 'mock-evm-keyring-tiebreaker'; + const selectedKeyringId = 'mock-selected-keyring-tiebreaker'; + + const trxAccount: InternalAccount = { + id: 'mock-trx-tiebreaker-id', + address: 'TRXtiebreaker', + options: { + entropy: { + type: KeyringAccountEntropyTypeOption.Mnemonic, + id: trxKeyringId, + groupIndex: 0, + derivationPath: '', + }, + }, + methods: [TrxMethod.SignMessageV2], + type: TrxAccountType.Eoa, + scopes: [TrxScope.Mainnet], + metadata: { + name: '', + keyring: { type: KeyringTypes.hd }, + importTime: 0, + lastSelected: 0, + }, + }; + + const evmAccount: Bip44Account = { + ...MOCK_HD_ACCOUNT_1, + id: 'mock-evm-tiebreaker-id', + address: '0xEVMtiebreaker', + options: { + ...MOCK_HD_ACCOUNT_1.options, + entropy: { + ...MOCK_HD_ACCOUNT_1.options.entropy, + id: evmKeyringId, + groupIndex: 0, + }, + }, + metadata: { ...MOCK_HD_ACCOUNT_1.metadata, importTime: 1 }, + }; + + const selectedAccount: Bip44Account = { + ...MOCK_HD_ACCOUNT_1, + id: 'mock-selected-tiebreaker-id', + address: '0xSELtiebreaker', + options: { + ...MOCK_HD_ACCOUNT_1.options, + entropy: { + ...MOCK_HD_ACCOUNT_1.options.entropy, + id: selectedKeyringId, + groupIndex: 0, + }, + }, + metadata: { ...MOCK_HD_ACCOUNT_1.metadata, importTime: 2 }, + }; + + const { controller, messenger } = setup({ + accounts: [trxAccount, evmAccount, selectedAccount], + keyrings: [ + { + type: KeyringTypes.hd, + metadata: { id: trxKeyringId, name: 'TRX Keyring' }, + accounts: ['TRXtiebreaker'], + }, + { + type: KeyringTypes.hd, + metadata: { id: evmKeyringId, name: 'EVM Keyring' }, + accounts: ['0xEVMtiebreaker'], + }, + { + type: KeyringTypes.hd, + metadata: { id: selectedKeyringId, name: 'Selected Keyring' }, + accounts: ['0xSELtiebreaker'], + }, + ], + }); + + controller.init(); + + const trxWalletId = toMultichainAccountWalletId(trxKeyringId); + const trxGroupId = toMultichainAccountGroupId(trxWalletId, 0); + const evmWalletId = toMultichainAccountWalletId(evmKeyringId); + const evmGroupId = toMultichainAccountGroupId(evmWalletId, 0); + const selectedWalletId = toMultichainAccountWalletId(selectedKeyringId); + const selectedGroupId = toMultichainAccountGroupId(selectedWalletId, 0); + + // Only select the third group so TRX and EVM groups keep lastSelected=0 + controller.setSelectedAccountGroup(selectedGroupId); + + // Remove the selected account — triggers #getDefaultAccountGroupId + // Iteration order: TRX group (importTime=0), EVM group (importTime=1), selected (empty) + // TRX: !candidate=true → candidate=TRX + // EVM: T equal (0===0), #groupHasEvmAccount(EVM)=true, !#groupHasEvmAccount(TRX)=true + // → EVM tiebreaker (line 1371) → candidate=EVM + // → #groupHasEvmAccount(TRX) returns false (line 1391) + // selected: accounts.length=0 → skip + messenger.publish('AccountsController:accountsRemoved', [ + selectedAccount.id, + ]); + + // EVM group wins via tiebreaker even though both have lastSelected=0 + expect(controller.getSelectedAccountGroup()).toBe(evmGroupId); + + // Also verify TRX group is still present (only selected group was removed) + const { state } = controller; + expect(state.accountTree.wallets[trxWalletId]).toBeDefined(); + expect( + state.accountTree.wallets[trxWalletId].groups[trxGroupId], + ).toBeDefined(); + }); }); describe('account removal and memory management', () => { @@ -2267,6 +2709,7 @@ describe('AccountTreeController', () => { value: false, lastUpdatedAt: 0, }, + lastSelected: 0, }); }); @@ -2420,6 +2863,7 @@ describe('AccountTreeController', () => { value: false, lastUpdatedAt: 0, }, + lastSelected: 0, }); }); @@ -2461,6 +2905,7 @@ describe('AccountTreeController', () => { value: true, lastUpdatedAt: expect.any(Number), }, + lastSelected: 0, }); }); @@ -5002,6 +5447,7 @@ describe('AccountTreeController', () => { value: false, lastUpdatedAt: 0, }, + lastSelected: 0, }); }); @@ -5051,6 +5497,7 @@ describe('AccountTreeController', () => { value: false, lastUpdatedAt: 0, }, + lastSelected: 0, }); }); @@ -5100,6 +5547,7 @@ describe('AccountTreeController', () => { value: true, lastUpdatedAt: 0, }, + lastSelected: 0, }); }); diff --git a/packages/account-tree-controller/src/AccountTreeController.ts b/packages/account-tree-controller/src/AccountTreeController.ts index 5dce17beb2e..1ffc3151fa3 100644 --- a/packages/account-tree-controller/src/AccountTreeController.ts +++ b/packages/account-tree-controller/src/AccountTreeController.ts @@ -727,6 +727,12 @@ export class AccountTreeController extends BaseController< // If any accounts was previously hidden, then we consider the group to be hidden as well. group.metadata.hidden = isHidden; } + + // Apply persisted lastSelected (plain number, not synced). + group.metadata.lastSelected = persistedGroupMetadata?.lastSelected ?? 0; + if (persistedGroupMetadata?.lastSelected === undefined) { + state.accountGroupsMetadata[groupId].lastSelected = 0; + } } /** @@ -1033,7 +1039,7 @@ export class AccountTreeController extends BaseController< accounts: [id], metadata: { name: '', - ...{ pinned: false, hidden: false }, // Default UI states + ...{ pinned: false, hidden: false, lastSelected: 0 }, // Default UI states ...result.group.metadata, // Allow rules to override defaults }, // We do need to type-cast since we're not narrowing `result` with @@ -1162,6 +1168,18 @@ export class AccountTreeController extends BaseController< // Update our state first this.update((state) => { state.selectedAccountGroup = groupId; + /* istanbul ignore next */ + if (!state.accountGroupsMetadata[groupId]) { + state.accountGroupsMetadata[groupId] = {}; + } + const now = Date.now(); + state.accountGroupsMetadata[groupId].lastSelected = now; + const walletId = this.#groupIdToWalletId.get(groupId); + if (walletId) { + state.accountTree.wallets[walletId].groups[ + groupId + ].metadata.lastSelected = now; + } }); log(`Selected group is now: [${this.state.selectedAccountGroup}]`); @@ -1229,6 +1247,18 @@ export class AccountTreeController extends BaseController< // Update selectedAccountGroup to match the selected account this.update((state) => { state.selectedAccountGroup = groupId; + /* istanbul ignore next */ + if (!state.accountGroupsMetadata[groupId]) { + state.accountGroupsMetadata[groupId] = {}; + } + const now = Date.now(); + state.accountGroupsMetadata[groupId].lastSelected = now; + const walletId = this.#groupIdToWalletId.get(groupId); + if (walletId) { + state.accountTree.wallets[walletId].groups[ + groupId + ].metadata.lastSelected = now; + } }); this.messenger.publish( `${controllerName}:selectedAccountGroupChange`, @@ -1307,41 +1337,56 @@ export class AccountTreeController extends BaseController< } /** - * Gets the default group id, which is either, the first non-empty group that contains an EVM account or - * just the first non-empty group with any accounts. + * Gets the default group id by selecting the most recently selected non-empty group. + * Groups that have never been explicitly selected (lastSelected === 0) are still + * considered as fallbacks. When timestamps are equal, groups containing EVM accounts + * are preferred as a tiebreaker. * * @param wallets - The wallets object to search. - * @returns The ID of the first non-empty group, or an empty string if no groups are found. + * @returns The ID of the most recently selected non-empty group, or an empty string if none found. */ #getDefaultAccountGroupId(wallets: { [walletId: AccountWalletId]: AccountWalletObject; }): AccountGroupId | '' { - let candidate: AccountGroupId | '' = ''; + let candidate: AccountGroupObject | undefined; for (const wallet of Object.values(wallets)) { for (const group of Object.values(wallet.groups)) { - // We only update the candidate with the first non-empty group, but still - // try to find a group that contains an EVM account (the `candidate` is - // our fallback). - if (candidate === '' && group.accounts.length > 0) { - candidate = group.id; + if (group.accounts.length === 0) { + continue; } - - for (const id of group.accounts) { - const account = this.messenger.call( - 'AccountsController:getAccount', - id, - ); - - if (account && isEvmAccountType(account.type)) { - // EVM accounts have a higher priority, so if we find any, we just - // use that group! - return group.id; - } + if (!candidate) { + candidate = group; + continue; } + if (group.metadata.lastSelected > candidate.metadata.lastSelected) { + candidate = group; + } else if ( + group.metadata.lastSelected === candidate.metadata.lastSelected && + this.#hasEvmAccount(group) && + !this.#hasEvmAccount(candidate) + ) { + candidate = group; + } + } + } + return candidate?.id ?? ''; + } + + /** + * Checks if a group contains at least one EVM account. + * + * @param group - The account group to check. + * @returns True if the group contains an EVM account, false otherwise. + */ + #hasEvmAccount(group: AccountGroupObject): boolean { + for (const id of group.accounts) { + const account = this.messenger.call('AccountsController:getAccount', id); + if (account && isEvmAccountType(account.type)) { + return true; } } - return candidate; + return false; } /** diff --git a/packages/account-tree-controller/src/group.ts b/packages/account-tree-controller/src/group.ts index 7eda76d8d73..3c5ca821415 100644 --- a/packages/account-tree-controller/src/group.ts +++ b/packages/account-tree-controller/src/group.ts @@ -27,6 +27,8 @@ export type AccountTreeGroupPersistedMetadata = { pinned?: UpdatableField; /** Whether this group is hidden in the UI */ hidden?: UpdatableField; + /** Timestamp of the last time this group was selected (local-only, not synced) */ + lastSelected?: number; }; export const MAX_SORT_ORDER = 9999; diff --git a/packages/account-tree-controller/src/rules/entropy.ts b/packages/account-tree-controller/src/rules/entropy.ts index 392652213cd..acf5d8f1bab 100644 --- a/packages/account-tree-controller/src/rules/entropy.ts +++ b/packages/account-tree-controller/src/rules/entropy.ts @@ -74,6 +74,7 @@ export class EntropyRule }, pinned: false, hidden: false, + lastSelected: 0, }, }, }; diff --git a/packages/account-tree-controller/src/rules/keyring.ts b/packages/account-tree-controller/src/rules/keyring.ts index 881e8d86d74..13bb287266b 100644 --- a/packages/account-tree-controller/src/rules/keyring.ts +++ b/packages/account-tree-controller/src/rules/keyring.ts @@ -126,6 +126,7 @@ export class KeyringRule metadata: { pinned: false, hidden: false, + lastSelected: 0, }, }, }; diff --git a/packages/account-tree-controller/src/rules/snap.ts b/packages/account-tree-controller/src/rules/snap.ts index 8787c775d3d..2e10f1a5351 100644 --- a/packages/account-tree-controller/src/rules/snap.ts +++ b/packages/account-tree-controller/src/rules/snap.ts @@ -76,6 +76,7 @@ export class SnapRule metadata: { pinned: false, hidden: false, + lastSelected: 0, }, }, };