From 3202dafa51116fe6361eaf3bf98c559910be565d Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Thu, 11 Jun 2026 18:14:02 +0200 Subject: [PATCH 1/9] feat: add group.status --- packages/account-tree-controller/CHANGELOG.md | 5 + .../src/AccountTreeController.test.ts | 63 ++++++++++ .../src/AccountTreeController.ts | 43 ++++++- packages/account-tree-controller/src/group.ts | 2 + packages/account-tree-controller/src/types.ts | 8 +- .../tests/mockMessenger.ts | 1 + .../multichain-account-service/CHANGELOG.md | 3 + .../src/MultichainAccountGroup.test.ts | 117 ++++++++++++++++++ .../src/MultichainAccountGroup.ts | 72 ++++++++++- .../src/MultichainAccountWallet.ts | 20 ++- .../multichain-account-service/src/index.ts | 2 + .../multichain-account-service/src/types.ts | 19 ++- 12 files changed, 347 insertions(+), 8 deletions(-) diff --git a/packages/account-tree-controller/CHANGELOG.md b/packages/account-tree-controller/CHANGELOG.md index 87714fcf70..aaed8e06b7 100644 --- a/packages/account-tree-controller/CHANGELOG.md +++ b/packages/account-tree-controller/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **BREAKING:** Expose `status` on `AccountGroupMultichainAccountObject` ([#TODO](https://github.com/MetaMask/core/pull/TODO)) + - The field reflects `MultichainAccountGroupStatus` and is kept in sync via the new `MultichainAccountService:groupStatusChange` event subscription. + ### Changed - Bump `@metamask/utils` from `^11.9.0` to `^11.11.0` ([#9074](https://github.com/MetaMask/core/pull/9074)) diff --git a/packages/account-tree-controller/src/AccountTreeController.test.ts b/packages/account-tree-controller/src/AccountTreeController.test.ts index dfdf7bfa5e..06f81b5c86 100644 --- a/packages/account-tree-controller/src/AccountTreeController.test.ts +++ b/packages/account-tree-controller/src/AccountTreeController.test.ts @@ -254,6 +254,7 @@ const MOCK_PREPOPULATED_STATE: Partial = { [MOCK_PREPOPULATED_GROUP_ID]: { id: MOCK_PREPOPULATED_GROUP_ID, type: AccountGroupType.MultichainAccount, + status: 'uninitialized', accounts: [MOCK_HD_ACCOUNT_1.id], metadata: { name: 'Account 1', @@ -562,6 +563,7 @@ describe('AccountTreeController', () => { [expectedWalletId1Group]: { id: expectedWalletId1Group, type: AccountGroupType.MultichainAccount, + status: 'uninitialized', accounts: [MOCK_HD_ACCOUNT_1.id], metadata: { name: 'Account 1', @@ -589,6 +591,7 @@ describe('AccountTreeController', () => { [expectedWalletId2Group1]: { id: expectedWalletId2Group1, type: AccountGroupType.MultichainAccount, + status: 'uninitialized', accounts: [MOCK_HD_ACCOUNT_2.id], metadata: { name: 'Account 1', // Updated: per-wallet numbering (wallet 2, account 1) @@ -603,6 +606,7 @@ describe('AccountTreeController', () => { [expectedWalletId2Group2]: { id: expectedWalletId2Group2, type: AccountGroupType.MultichainAccount, + status: 'uninitialized', accounts: [MOCK_SNAP_ACCOUNT_1.id], metadata: { name: 'Account 2', // Updated: per-wallet sequential numbering (wallet 2, account 2) @@ -1286,6 +1290,7 @@ describe('AccountTreeController', () => { [walletId1Group]: { id: walletId1Group, type: AccountGroupType.MultichainAccount, + status: 'uninitialized', metadata: { name: 'Account 1', entropy: { @@ -1377,6 +1382,7 @@ describe('AccountTreeController', () => { [walletId1Group2]: { id: walletId1Group2, type: AccountGroupType.MultichainAccount, + status: 'uninitialized', metadata: { name: 'Account 2', entropy: { @@ -1612,6 +1618,7 @@ describe('AccountTreeController', () => { [walletId1Group]: { id: walletId1Group, type: AccountGroupType.MultichainAccount, + status: 'uninitialized', metadata: { name: 'Account 1', entropy: { @@ -1720,6 +1727,7 @@ describe('AccountTreeController', () => { [walletId1Group]: { id: walletId1Group, type: AccountGroupType.MultichainAccount, + status: 'uninitialized', metadata: { name: 'Account 1', entropy: { @@ -1748,6 +1756,7 @@ describe('AccountTreeController', () => { [walletId2Group]: { id: walletId2Group, type: AccountGroupType.MultichainAccount, + status: 'uninitialized', metadata: { name: 'Account 1', // Updated: per-wallet naming (different wallet) entropy: { @@ -1871,6 +1880,60 @@ describe('AccountTreeController', () => { }); }); + describe('on MultichainAccountService:groupStatusChange', () => { + it('updates the group status when the event is published', () => { + const { controller, messenger } = setup({ + accounts: [MOCK_HD_ACCOUNT_1], + keyrings: [MOCK_HD_KEYRING_1], + }); + controller.init(); + + const walletId = MOCK_PREPOPULATED_WALLET_ID; + const groupId = MOCK_PREPOPULATED_GROUP_ID; + + expect( + controller.state.accountTree.wallets[walletId]?.groups[groupId] + ?.status, + ).toBe('uninitialized'); + + messenger.publish( + 'MultichainAccountService:groupStatusChange', + groupId, + 'in-progress:alignment', + ); + expect( + controller.state.accountTree.wallets[walletId]?.groups[groupId] + ?.status, + ).toBe('in-progress:alignment'); + + messenger.publish( + 'MultichainAccountService:groupStatusChange', + groupId, + 'aligned', + ); + expect( + controller.state.accountTree.wallets[walletId]?.groups[groupId] + ?.status, + ).toBe('aligned'); + }); + + it('does nothing when the group ID is unknown', () => { + const { controller, messenger } = setup({ + accounts: [MOCK_HD_ACCOUNT_1], + keyrings: [MOCK_HD_KEYRING_1], + }); + controller.init(); + + expect(() => + messenger.publish( + 'MultichainAccountService:groupStatusChange', + 'unknown-group-id' as ReturnType, + 'aligned', + ), + ).not.toThrow(); + }); + }); + describe('getAccountWalletObject', () => { it('gets a wallet using its ID', () => { const { controller } = setup({ diff --git a/packages/account-tree-controller/src/AccountTreeController.ts b/packages/account-tree-controller/src/AccountTreeController.ts index 5640662e9f..0b257fb779 100644 --- a/packages/account-tree-controller/src/AccountTreeController.ts +++ b/packages/account-tree-controller/src/AccountTreeController.ts @@ -1,10 +1,14 @@ -import { AccountWalletType, select } from '@metamask/account-api'; +import { + AccountGroupType, + AccountWalletType, + select, +} from '@metamask/account-api'; import type { AccountGroupId, AccountWalletId, AccountSelector, + MultichainAccountGroupId, MultichainAccountWalletId, - AccountGroupType, } from '@metamask/account-api'; import type { MultichainAccountWalletStatus } from '@metamask/account-api'; import type { AccountId } from '@metamask/accounts-controller'; @@ -13,6 +17,7 @@ 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 type { MultichainAccountGroupStatus } from '@metamask/multichain-account-service'; import { assert } from '@metamask/utils'; import type { BackupAndSyncEmitAnalyticsEventParams } from './backup-and-sync/analytics'; @@ -277,6 +282,13 @@ export class AccountTreeController extends BaseController< }, ); + this.messenger.subscribe( + 'MultichainAccountService:groupStatusChange', + (groupId, status) => { + this.#handleMultichainAccountGroupStatusChange(groupId, status); + }, + ); + this.messenger.registerMethodActionHandlers( this, MESSENGER_EXPOSED_METHODS, @@ -1173,6 +1185,11 @@ export class AccountTreeController extends BaseController< ...result.group, // Type-wise, we are guaranteed to always have at least 1 account. accounts: [id], + // Entropy (multichain) groups start as 'uninitialized'; the service will + // publish a groupStatusChange event to set the real status shortly after. + ...(result.group.type === AccountGroupType.MultichainAccount && { + status: 'uninitialized', + }), metadata: { name: '', ...{ pinned: false, hidden: false, lastSelected: 0 }, // Default UI states @@ -1423,6 +1440,28 @@ export class AccountTreeController extends BaseController< }); } + /** + * Handles multichain account group status change from + * the MultichainAccountService. + * + * @param groupId - Multichain account group ID. + * @param groupStatus - New multichain account group status. + */ + #handleMultichainAccountGroupStatusChange( + groupId: MultichainAccountGroupId, + groupStatus: MultichainAccountGroupStatus, + ): void { + this.update((state) => { + const walletId = this.#groupIdToWalletId.get(groupId); + if (walletId) { + const group = state.accountTree.wallets[walletId]?.groups[groupId]; + if (group?.type === AccountGroupType.MultichainAccount) { + group.status = groupStatus; + } + } + }); + } + /** * Gets account group object. * diff --git a/packages/account-tree-controller/src/group.ts b/packages/account-tree-controller/src/group.ts index 5e53cbc5a0..e108032e7f 100644 --- a/packages/account-tree-controller/src/group.ts +++ b/packages/account-tree-controller/src/group.ts @@ -13,6 +13,7 @@ import { XlmAccountType, } from '@metamask/keyring-api'; import type { KeyringAccountType } from '@metamask/keyring-api'; +import type { MultichainAccountGroupStatus } from '@metamask/multichain-account-service'; import type { UpdatableField, ExtractFieldValues } from './type-utils'; import type { AccountTreeControllerState } from './types'; @@ -79,6 +80,7 @@ type IsAccountGroupObject< export type AccountGroupMultichainAccountObject = { type: AccountGroupType.MultichainAccount; id: MultichainAccountGroupId; + status: MultichainAccountGroupStatus; // Blockchain Accounts (at least 1 account per multichain-accounts): accounts: [AccountId, ...AccountId[]]; metadata: AccountTreeGroupMetadata & { diff --git a/packages/account-tree-controller/src/types.ts b/packages/account-tree-controller/src/types.ts index e7a6657139..e32e96e716 100644 --- a/packages/account-tree-controller/src/types.ts +++ b/packages/account-tree-controller/src/types.ts @@ -20,7 +20,10 @@ import type { MultichainAccountServiceCreateMultichainAccountGroupAction, MultichainAccountServiceCreateMultichainAccountGroupsAction, } from '@metamask/multichain-account-service'; -import type { MultichainAccountServiceWalletStatusChangeEvent } from '@metamask/multichain-account-service'; +import type { + MultichainAccountServiceGroupStatusChangeEvent, + MultichainAccountServiceWalletStatusChangeEvent, +} from '@metamask/multichain-account-service'; import type { AuthenticationController, UserStorageController, @@ -158,7 +161,8 @@ export type AllowedEvents = | AccountsControllerAccountsRemovedEvent | AccountsControllerSelectedAccountChangeEvent | UserStorageController.UserStorageControllerStateChangeEvent - | MultichainAccountServiceWalletStatusChangeEvent; + | MultichainAccountServiceWalletStatusChangeEvent + | MultichainAccountServiceGroupStatusChangeEvent; export type AccountTreeControllerEvents = | AccountTreeControllerStateChangeEvent diff --git a/packages/account-tree-controller/tests/mockMessenger.ts b/packages/account-tree-controller/tests/mockMessenger.ts index a96594dbf7..6b43a124bf 100644 --- a/packages/account-tree-controller/tests/mockMessenger.ts +++ b/packages/account-tree-controller/tests/mockMessenger.ts @@ -49,6 +49,7 @@ export function getAccountTreeControllerMessenger( 'AccountsController:selectedAccountChange', 'UserStorageController:stateChange', 'MultichainAccountService:walletStatusChange', + 'MultichainAccountService:groupStatusChange', ], actions: [ 'AccountsController:listMultichainAccounts', diff --git a/packages/multichain-account-service/CHANGELOG.md b/packages/multichain-account-service/CHANGELOG.md index d859e80892..7039950439 100644 --- a/packages/multichain-account-service/CHANGELOG.md +++ b/packages/multichain-account-service/CHANGELOG.md @@ -16,6 +16,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Use this if you need to access the inner (wrapped) keyring. - Add `isAligned` ([#9039](https://github.com/MetaMask/core/pull/9039)) - This allows callers to cheaply check whether alignment has already occurred before triggering an explicit alignment operation. +- Add `MultichainAccountGroupStatus` type and `MultichainAccountServiceGroupStatusChangeEvent` event ([#TODO](https://github.com/MetaMask/core/pull/TODO)) + - `MultichainAccountGroup` now tracks a `status` field (`'uninitialized' | 'in-progress:create-accounts' | 'in-progress:alignment' | 'aligned' | 'misaligned'`). + - The service messenger emits `MultichainAccountService:groupStatusChange` whenever a group's status changes. ### Changed diff --git a/packages/multichain-account-service/src/MultichainAccountGroup.test.ts b/packages/multichain-account-service/src/MultichainAccountGroup.test.ts index 927b6d6e76..8121a9ea39 100644 --- a/packages/multichain-account-service/src/MultichainAccountGroup.test.ts +++ b/packages/multichain-account-service/src/MultichainAccountGroup.test.ts @@ -180,6 +180,123 @@ describe('MultichainAccountGroup', () => { }); }); + describe('status', () => { + it('starts as uninitialized before init()', () => { + const serviceMessenger = + getMultichainAccountServiceMessenger(getRootMessenger()); + const providers = [ + setupBip44AccountProvider({ + name: 'Provider 1', + accounts: [MOCK_WALLET_1_EVM_ACCOUNT], + }), + ]; + const wallet = new MultichainAccountWallet({ + entropySource: MOCK_WALLET_1_ENTROPY_SOURCE, + messenger: serviceMessenger, + providers, + }); + const group = new MultichainAccountGroup({ + wallet, + groupIndex: 0, + providers, + messenger: serviceMessenger, + }); + + expect(group.status).toBe('uninitialized'); + }); + + it('is aligned after init() when all providers have accounts', () => { + const { group } = setup({ + accounts: [[MOCK_WALLET_1_EVM_ACCOUNT], [MOCK_WALLET_1_SOL_ACCOUNT]], + }); + + expect(group.status).toBe('aligned'); + }); + + it('is misaligned after init() when a provider has no accounts', () => { + const { group } = setup({ + accounts: [[MOCK_WALLET_1_EVM_ACCOUNT], []], + }); + + expect(group.status).toBe('misaligned'); + }); + + it('publishes groupStatusChange event when withState transitions to in-progress then settles', async () => { + const { group, messenger: serviceMessenger } = setup({ + accounts: [[MOCK_WALLET_1_EVM_ACCOUNT], []], + }); + const publishSpy = jest.spyOn(serviceMessenger, 'publish'); + + await group.withState('in-progress:alignment', async () => { + expect(group.status).toBe('in-progress:alignment'); + }); + + expect(group.status).toBe('misaligned'); + expect(publishSpy).toHaveBeenCalledWith( + 'MultichainAccountService:groupStatusChange', + group.id, + 'in-progress:alignment', + ); + expect(publishSpy).toHaveBeenCalledWith( + 'MultichainAccountService:groupStatusChange', + group.id, + 'misaligned', + ); + }); + + it('preserves in-progress:create-accounts through an inner in-progress:alignment withState', async () => { + const { group } = setup({ + accounts: [[MOCK_WALLET_1_EVM_ACCOUNT], []], + }); + const statusDuringInner: string[] = []; + + await group.withState('in-progress:create-accounts', async () => { + // Inner withState with a different in-progress status should not override + await group.withState('in-progress:alignment', async () => { + statusDuringInner.push(group.status); + }); + statusDuringInner.push(group.status); + }); + + // 'in-progress:create-accounts' is preserved through the inner call since + // the guard skips the entry when already in any in-progress state. + expect(statusDuringInner[0]).toBe('in-progress:create-accounts'); + // After both finally blocks fire, status is 'misaligned'. + expect(group.status).toBe('misaligned'); + }); + + it('auto-corrects status on update() when not in an in-progress state', () => { + const { group, providers } = setup({ + accounts: [[MOCK_WALLET_1_EVM_ACCOUNT], []], + }); + expect(group.status).toBe('misaligned'); + + // Simulate provider 2 now being considered aligned (e.g. disabled wrapper) + providers[1].isAligned.mockReturnValue(true); + group.update({ + 'Provider 1': [MOCK_WALLET_1_EVM_ACCOUNT.id], + }); + + expect(group.status).toBe('aligned'); + }); + + it('does not override in-progress status on update()', async () => { + const { group, providers } = setup({ + accounts: [[MOCK_WALLET_1_EVM_ACCOUNT], []], + }); + + await group.withState('in-progress:alignment', async () => { + // update() called inside withState should NOT change the status + providers[1].isAligned.mockReturnValue(true); + group.update({ 'Provider 1': [MOCK_WALLET_1_EVM_ACCOUNT.id] }); + expect(group.status).toBe('in-progress:alignment'); + }); + + // After withState finalizes, status should reflect isAligned() + expect(group.status).toBe('aligned'); + }); + }); + describe('isAligned', () => { it('returns true when every provider has at least one account in the group', () => { const { group } = setup({ diff --git a/packages/multichain-account-service/src/MultichainAccountGroup.ts b/packages/multichain-account-service/src/MultichainAccountGroup.ts index 8b7af73e08..33f770b84d 100644 --- a/packages/multichain-account-service/src/MultichainAccountGroup.ts +++ b/packages/multichain-account-service/src/MultichainAccountGroup.ts @@ -13,7 +13,10 @@ import { projectLogger as log, createModuleLogger } from './logger'; import type { ServiceState, StateKeys } from './MultichainAccountService'; import type { MultichainAccountWallet } from './MultichainAccountWallet'; import type { Bip44AccountProvider } from './providers'; -import type { MultichainAccountServiceMessenger } from './types'; +import type { + MultichainAccountGroupStatus, + MultichainAccountServiceMessenger, +} from './types'; export type GroupState = ServiceState[StateKeys['entropySource']][StateKeys['groupIndex']]; @@ -48,6 +51,8 @@ export class MultichainAccountGroup< #initialized = false; + #status: MultichainAccountGroupStatus = 'uninitialized'; + constructor({ groupIndex, wallet, @@ -114,6 +119,9 @@ export class MultichainAccountGroup< this.#log('Finished initializing group state...'); this.#initialized = true; + // Set initial status without publishing — mirrors wallet init() pattern where the tree + // hardcodes its own initial state and events only flow after the first mutation. + this.#status = this.isAligned() ? 'aligned' : 'misaligned'; } /** @@ -127,6 +135,14 @@ export class MultichainAccountGroup< this.#log('Finished updating group state...'); if (this.#initialized) { + // Auto-correct status for dynamic account changes that happen outside any + // explicit operation (e.g. a new provider added at runtime). During an + // operation, `withState` owns the status and its `finally` block finalizes it, + // so we skip the auto-update to avoid clobbering the in-progress state. + if (!this.#status.startsWith('in-progress:')) { + this.#setStatus(this.isAligned() ? 'aligned' : 'misaligned'); + } + this.#messenger.publish( 'MultichainAccountService:multichainAccountGroupUpdated', this, @@ -134,6 +150,60 @@ export class MultichainAccountGroup< } } + /** + * Gets the current status of this group. + * + * @returns The group status. + */ + get status(): MultichainAccountGroupStatus { + return this.#status; + } + + /** + * Runs an async operation under a specific in-progress status, then auto-finalizes + * the group status to `'aligned'` or `'misaligned'` in the `finally` block. + * + * Mirrors the wallet's `#withLock` pattern — without acquiring a lock (the wallet's + * mutex already serializes all mutable group operations). + * + * @param status - The in-progress status to set before the operation. + * @param operation - The operation to run. + * @returns The operation's result. + */ + async withState( + status: 'in-progress:create-accounts' | 'in-progress:alignment', + operation: () => Promise, + ): Promise { + // Do not override an in-progress status that was set by an outer caller + // (e.g. 'in-progress:create-accounts' must survive through the inner + // #alignAccountsForRange 'in-progress:alignment' withState call). + if (!this.#status.startsWith('in-progress:')) { + this.#setStatus(status); + } + try { + return await operation(); + } finally { + this.#setStatus(this.isAligned() ? 'aligned' : 'misaligned'); + } + } + + /** + * Sets the group status and publishes the status-change event. + * No-ops when the group has not been initialized yet. + * + * @param status - The new status. + */ + #setStatus(status: MultichainAccountGroupStatus): void { + this.#status = status; + if (this.#initialized) { + this.#messenger.publish( + 'MultichainAccountService:groupStatusChange', + this.#id, + this.#status, + ); + } + } + /** * Gets the multichain account group ID. * diff --git a/packages/multichain-account-service/src/MultichainAccountWallet.ts b/packages/multichain-account-service/src/MultichainAccountWallet.ts index 731fa3f777..aec2aaafdc 100644 --- a/packages/multichain-account-service/src/MultichainAccountWallet.ts +++ b/packages/multichain-account-service/src/MultichainAccountWallet.ts @@ -447,8 +447,13 @@ export class MultichainAccountWallet< async #alignAccountsForRange( { from, to }: Required, providers: Bip44AccountProvider[], - options: { trace?: { data?: TraceRequest['data'] } } = {}, + options: { + trace?: { data?: TraceRequest['data'] }; + groupStatus?: 'in-progress:create-accounts' | 'in-progress:alignment'; + } = {}, ): Promise { + const groupStatus = options.groupStatus ?? 'in-progress:alignment'; + await this.#trace( { name: TraceName.WalletAlignment, @@ -475,7 +480,17 @@ export class MultichainAccountWallet< for (let groupIndex = from; groupIndex <= to; groupIndex++) { const groupState = groupStateByGroupIndex.get(groupIndex); if (groupState) { - this.#createOrUpdateMultichainAccountGroup(groupIndex, groupState); + const existingGroup = this.getMultichainAccountGroup(groupIndex); + assert( + existingGroup, + `Expected group at index ${groupIndex} to exist before alignment`, + ); + await existingGroup.withState(groupStatus, async () => { + this.#createOrUpdateMultichainAccountGroup( + groupIndex, + groupState, + ); + }); } } }, @@ -702,6 +717,7 @@ export class MultichainAccountWallet< post: true, // Tag to identify post-alignment traces in analytics. }, }, + groupStatus: 'in-progress:create-accounts', }); }); diff --git a/packages/multichain-account-service/src/index.ts b/packages/multichain-account-service/src/index.ts index 2c6dec78e8..f7127dd277 100644 --- a/packages/multichain-account-service/src/index.ts +++ b/packages/multichain-account-service/src/index.ts @@ -5,6 +5,8 @@ export type { MultichainAccountServiceMultichainAccountGroupCreatedEvent, MultichainAccountServiceMultichainAccountGroupUpdatedEvent, MultichainAccountServiceWalletStatusChangeEvent, + MultichainAccountGroupStatus, + MultichainAccountServiceGroupStatusChangeEvent, } from './types'; export type { MultichainAccountServiceResyncAccountsAction, diff --git a/packages/multichain-account-service/src/types.ts b/packages/multichain-account-service/src/types.ts index b640f49757..c04737201d 100644 --- a/packages/multichain-account-service/src/types.ts +++ b/packages/multichain-account-service/src/types.ts @@ -1,6 +1,7 @@ import type { Bip44Account, MultichainAccountGroup, + MultichainAccountGroupId, MultichainAccountWalletId, MultichainAccountWalletStatus, } from '@metamask/account-api'; @@ -57,6 +58,21 @@ export type MultichainAccountServiceWalletStatusChangeEvent = { payload: [MultichainAccountWalletId, MultichainAccountWalletStatus]; }; +/** + * Status of a multichain account group, mirroring the wallet-level status pattern. + */ +export type MultichainAccountGroupStatus = + | 'uninitialized' + | 'in-progress:create-accounts' + | 'in-progress:alignment' + | 'aligned' + | 'misaligned'; + +export type MultichainAccountServiceGroupStatusChangeEvent = { + type: `${typeof serviceName}:groupStatusChange`; + payload: [MultichainAccountGroupId, MultichainAccountGroupStatus]; +}; + /** * All events that {@link MultichainAccountService} publishes so that other modules * can subscribe to them. @@ -64,7 +80,8 @@ export type MultichainAccountServiceWalletStatusChangeEvent = { export type MultichainAccountServiceEvents = | MultichainAccountServiceMultichainAccountGroupCreatedEvent | MultichainAccountServiceMultichainAccountGroupUpdatedEvent - | MultichainAccountServiceWalletStatusChangeEvent; + | MultichainAccountServiceWalletStatusChangeEvent + | MultichainAccountServiceGroupStatusChangeEvent; /** * All actions registered by other modules that {@link MultichainAccountService} From 38144ff1dd70b5d8d4b41a4138684f313c168214 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Fri, 12 Jun 2026 10:59:06 +0200 Subject: [PATCH 2/9] chore: lint --- .../src/AccountTreeController.test.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/account-tree-controller/src/AccountTreeController.test.ts b/packages/account-tree-controller/src/AccountTreeController.test.ts index 06f81b5c86..0241a0e846 100644 --- a/packages/account-tree-controller/src/AccountTreeController.test.ts +++ b/packages/account-tree-controller/src/AccountTreeController.test.ts @@ -1892,8 +1892,7 @@ describe('AccountTreeController', () => { const groupId = MOCK_PREPOPULATED_GROUP_ID; expect( - controller.state.accountTree.wallets[walletId]?.groups[groupId] - ?.status, + controller.state.accountTree.wallets[walletId]?.groups[groupId]?.status, ).toBe('uninitialized'); messenger.publish( @@ -1902,8 +1901,7 @@ describe('AccountTreeController', () => { 'in-progress:alignment', ); expect( - controller.state.accountTree.wallets[walletId]?.groups[groupId] - ?.status, + controller.state.accountTree.wallets[walletId]?.groups[groupId]?.status, ).toBe('in-progress:alignment'); messenger.publish( @@ -1912,8 +1910,7 @@ describe('AccountTreeController', () => { 'aligned', ); expect( - controller.state.accountTree.wallets[walletId]?.groups[groupId] - ?.status, + controller.state.accountTree.wallets[walletId]?.groups[groupId]?.status, ).toBe('aligned'); }); From 94cb3c03cdbacc6689c8b915d24257f130424e8e Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Fri, 12 Jun 2026 11:01:24 +0200 Subject: [PATCH 3/9] chore: changelog --- packages/account-tree-controller/CHANGELOG.md | 3 ++- packages/multichain-account-service/CHANGELOG.md | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/account-tree-controller/CHANGELOG.md b/packages/account-tree-controller/CHANGELOG.md index aaed8e06b7..34bf01129c 100644 --- a/packages/account-tree-controller/CHANGELOG.md +++ b/packages/account-tree-controller/CHANGELOG.md @@ -9,7 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- **BREAKING:** Expose `status` on `AccountGroupMultichainAccountObject` ([#TODO](https://github.com/MetaMask/core/pull/TODO)) +- **BREAKING:** Expose `status` on `AccountGroupMultichainAccountObject` ([#9104](https://github.com/MetaMask/core/pull/9104)) + - The controller now requires the new event `MultichainAccountService:groupStatusChange`. - The field reflects `MultichainAccountGroupStatus` and is kept in sync via the new `MultichainAccountService:groupStatusChange` event subscription. ### Changed diff --git a/packages/multichain-account-service/CHANGELOG.md b/packages/multichain-account-service/CHANGELOG.md index 7039950439..6bab68b0ba 100644 --- a/packages/multichain-account-service/CHANGELOG.md +++ b/packages/multichain-account-service/CHANGELOG.md @@ -16,7 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Use this if you need to access the inner (wrapped) keyring. - Add `isAligned` ([#9039](https://github.com/MetaMask/core/pull/9039)) - This allows callers to cheaply check whether alignment has already occurred before triggering an explicit alignment operation. -- Add `MultichainAccountGroupStatus` type and `MultichainAccountServiceGroupStatusChangeEvent` event ([#TODO](https://github.com/MetaMask/core/pull/TODO)) +- Add `MultichainAccountGroupStatus` type and `MultichainAccountServiceGroupStatusChangeEvent` event ([#9104](https://github.com/MetaMask/core/pull/9104)) - `MultichainAccountGroup` now tracks a `status` field (`'uninitialized' | 'in-progress:create-accounts' | 'in-progress:alignment' | 'aligned' | 'misaligned'`). - The service messenger emits `MultichainAccountService:groupStatusChange` whenever a group's status changes. From 0c4253dc9da5084f5d10fb6e63fe55b98078deba Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Fri, 12 Jun 2026 16:02:59 +0200 Subject: [PATCH 4/9] chore: missing jsdocs param --- .../multichain-account-service/src/MultichainAccountWallet.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/multichain-account-service/src/MultichainAccountWallet.ts b/packages/multichain-account-service/src/MultichainAccountWallet.ts index aec2aaafdc..ca27a7a73f 100644 --- a/packages/multichain-account-service/src/MultichainAccountWallet.ts +++ b/packages/multichain-account-service/src/MultichainAccountWallet.ts @@ -443,6 +443,7 @@ export class MultichainAccountWallet< * @param options - Options. * @param options.trace - Trace options. * @param options.trace.data - Optional trace data. + * @param options.groupStatus - Optional status to set on groups during alignment or post-creation alignment (defaults to 'in-progress:alignment'). */ async #alignAccountsForRange( { from, to }: Required, From 448f811253dede36768114f43d1ce767129ba8bf Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Mon, 15 Jun 2026 09:23:29 +0200 Subject: [PATCH 5/9] fix: query group status during insert --- .../src/AccountTreeController.test.ts | 50 +++++++++++++++++++ .../src/AccountTreeController.ts | 36 +++++++++++-- packages/account-tree-controller/src/types.ts | 4 +- .../tests/mockMessenger.ts | 1 + 4 files changed, 85 insertions(+), 6 deletions(-) diff --git a/packages/account-tree-controller/src/AccountTreeController.test.ts b/packages/account-tree-controller/src/AccountTreeController.test.ts index 0241a0e846..b281a15254 100644 --- a/packages/account-tree-controller/src/AccountTreeController.test.ts +++ b/packages/account-tree-controller/src/AccountTreeController.test.ts @@ -342,6 +342,9 @@ function setup({ keyrings: KeyringObject[]; getState: jest.Mock; }; + MultichainAccountService: { + getMultichainAccountGroup: jest.Mock; + }; AccountsController: { accounts: InternalAccount[]; listMultichainAccounts: jest.Mock; @@ -365,6 +368,13 @@ function setup({ keyrings, getState: jest.fn(), }, + MultichainAccountService: { + // Default: service has no group (not yet initialized). #queryGroupStatus catches + // the throw and falls back to 'uninitialized'. Individual tests can override. + getMultichainAccountGroup: jest.fn().mockImplementation(() => { + throw new Error('Group not found'); + }), + }, AccountsController: { accounts, listMultichainAccounts: jest.fn(), @@ -389,6 +399,11 @@ function setup({ }, }; + messenger.registerActionHandler( + 'MultichainAccountService:getMultichainAccountGroup', + mocks.MultichainAccountService.getMultichainAccountGroup, + ); + if (accounts) { mocks.AccountsController.listMultichainAccounts.mockImplementation( () => mocks.AccountsController.accounts, @@ -1031,6 +1046,41 @@ describe('AccountTreeController', () => { expect(groupIds[1]).toBe(toMultichainAccountGroupId(walletId, 1)); expect(groupIds[2]).toBe(toMultichainAccountGroupId(walletId, 2)); }); + + it('reads group status from the service when it is already initialized', () => { + const { controller, mocks } = setup({ + accounts: [MOCK_HD_ACCOUNT_1], + keyrings: [MOCK_HD_KEYRING_1], + }); + + // Simulate the service having already initialized and the group being aligned. + mocks.MultichainAccountService.getMultichainAccountGroup.mockReturnValue({ + status: 'aligned', + }); + + controller.init(); + + const groupId = MOCK_PREPOPULATED_GROUP_ID; + const walletId = MOCK_PREPOPULATED_WALLET_ID; + expect( + controller.state.accountTree.wallets[walletId]?.groups[groupId]?.status, + ).toBe('aligned'); + }); + + it('falls back to uninitialized when the service has no record for the group yet', () => { + const { controller } = setup({ + accounts: [MOCK_HD_ACCOUNT_1], + keyrings: [MOCK_HD_KEYRING_1], + }); + // Default setup handler throws — fallback should apply. + controller.init(); + + const groupId = MOCK_PREPOPULATED_GROUP_ID; + const walletId = MOCK_PREPOPULATED_WALLET_ID; + expect( + controller.state.accountTree.wallets[walletId]?.groups[groupId]?.status, + ).toBe('uninitialized'); + }); }); describe('getAccountGroupObject', () => { diff --git a/packages/account-tree-controller/src/AccountTreeController.ts b/packages/account-tree-controller/src/AccountTreeController.ts index 0b257fb779..b5d5d8be99 100644 --- a/packages/account-tree-controller/src/AccountTreeController.ts +++ b/packages/account-tree-controller/src/AccountTreeController.ts @@ -1185,11 +1185,13 @@ export class AccountTreeController extends BaseController< ...result.group, // Type-wise, we are guaranteed to always have at least 1 account. accounts: [id], - // Entropy (multichain) groups start as 'uninitialized'; the service will - // publish a groupStatusChange event to set the real status shortly after. - ...(result.group.type === AccountGroupType.MultichainAccount && { - status: 'uninitialized', - }), + ...(result.group.type === AccountGroupType.MultichainAccount && + result.wallet.type === AccountWalletType.Entropy && { + status: this.#getMultichainAccountGroupStatus( + result.wallet.metadata.entropy.id, + result.group.metadata.entropy.groupIndex, + ), + }), metadata: { name: '', ...{ pinned: false, hidden: false, lastSelected: 0 }, // Default UI states @@ -1462,6 +1464,30 @@ export class AccountTreeController extends BaseController< }); } + /** + * Gets the multichain account group's current status from the service. + * Falls back to `'uninitialized'` when the service has no record for the + * group yet (e.g. the service hasn't finished its group or hasn't finished + * its own `init` call). + * + * @param entropySource - The entropy source ID of the wallet. + * @param groupIndex - The group index within that wallet. + * @returns The group's current status, or `'uninitialized'` if unknown. + */ + #getMultichainAccountGroupStatus( + entropySource: string, + groupIndex: number, + ): MultichainAccountGroupStatus { + try { + return this.messenger.call( + 'MultichainAccountService:getMultichainAccountGroup', + { entropySource, groupIndex }, + ).status; + } catch { + return 'uninitialized'; + } + } + /** * Gets account group object. * diff --git a/packages/account-tree-controller/src/types.ts b/packages/account-tree-controller/src/types.ts index e32e96e716..0d8826c5f1 100644 --- a/packages/account-tree-controller/src/types.ts +++ b/packages/account-tree-controller/src/types.ts @@ -19,6 +19,7 @@ import type { Messenger } from '@metamask/messenger'; import type { MultichainAccountServiceCreateMultichainAccountGroupAction, MultichainAccountServiceCreateMultichainAccountGroupsAction, + MultichainAccountServiceGetMultichainAccountGroupAction, } from '@metamask/multichain-account-service'; import type { MultichainAccountServiceGroupStatusChangeEvent, @@ -97,7 +98,8 @@ export type AllowedActions = | UserStorageController.UserStorageControllerPerformBatchSetStorageAction | AuthenticationController.AuthenticationControllerGetSessionProfileAction | MultichainAccountServiceCreateMultichainAccountGroupAction - | MultichainAccountServiceCreateMultichainAccountGroupsAction; + | MultichainAccountServiceCreateMultichainAccountGroupsAction + | MultichainAccountServiceGetMultichainAccountGroupAction; export type AccountTreeControllerActions = | AccountTreeControllerGetStateAction diff --git a/packages/account-tree-controller/tests/mockMessenger.ts b/packages/account-tree-controller/tests/mockMessenger.ts index 6b43a124bf..a40e3d67fa 100644 --- a/packages/account-tree-controller/tests/mockMessenger.ts +++ b/packages/account-tree-controller/tests/mockMessenger.ts @@ -62,6 +62,7 @@ export function getAccountTreeControllerMessenger( 'UserStorageController:performSetStorage', 'UserStorageController:performBatchSetStorage', 'AuthenticationController:getSessionProfile', + 'MultichainAccountService:getMultichainAccountGroup', 'MultichainAccountService:createMultichainAccountGroup', 'KeyringController:getState', 'SnapController:getSnap', From da0cd875b1c7279ae8f7be9919f2fd111c352926 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Mon, 15 Jun 2026 10:21:22 +0200 Subject: [PATCH 6/9] fix: query wallet status during insert --- .../src/AccountTreeController.test.ts | 50 ++++++++++++++++++- .../src/AccountTreeController.ts | 24 ++++++++- packages/account-tree-controller/src/types.ts | 4 +- .../tests/mockMessenger.ts | 1 + 4 files changed, 75 insertions(+), 4 deletions(-) diff --git a/packages/account-tree-controller/src/AccountTreeController.test.ts b/packages/account-tree-controller/src/AccountTreeController.test.ts index b281a15254..2e6284e643 100644 --- a/packages/account-tree-controller/src/AccountTreeController.test.ts +++ b/packages/account-tree-controller/src/AccountTreeController.test.ts @@ -344,6 +344,7 @@ function setup({ }; MultichainAccountService: { getMultichainAccountGroup: jest.Mock; + getMultichainAccountWallet: jest.Mock; }; AccountsController: { accounts: InternalAccount[]; @@ -369,11 +370,14 @@ function setup({ getState: jest.fn(), }, MultichainAccountService: { - // Default: service has no group (not yet initialized). #queryGroupStatus catches - // the throw and falls back to 'uninitialized'. Individual tests can override. + // Default: service has no record yet. The query helpers catch the throw and + // fall back to 'uninitialized'. Individual tests can override via mockReturnValue. getMultichainAccountGroup: jest.fn().mockImplementation(() => { throw new Error('Group not found'); }), + // Default: service is initialized and wallet is ready (the common case). + // Individual tests can override via mockReturnValue to test other statuses. + getMultichainAccountWallet: jest.fn().mockReturnValue({ status: 'ready' }), }, AccountsController: { accounts, @@ -403,6 +407,10 @@ function setup({ 'MultichainAccountService:getMultichainAccountGroup', mocks.MultichainAccountService.getMultichainAccountGroup, ); + messenger.registerActionHandler( + 'MultichainAccountService:getMultichainAccountWallet', + mocks.MultichainAccountService.getMultichainAccountWallet, + ); if (accounts) { mocks.AccountsController.listMultichainAccounts.mockImplementation( @@ -1047,6 +1055,44 @@ describe('AccountTreeController', () => { expect(groupIds[2]).toBe(toMultichainAccountGroupId(walletId, 2)); }); + it('reads wallet status from the service when it is already initialized', () => { + const { controller, mocks } = setup({ + accounts: [MOCK_HD_ACCOUNT_1], + keyrings: [MOCK_HD_KEYRING_1], + }); + + mocks.MultichainAccountService.getMultichainAccountWallet.mockReturnValue({ + status: 'in-progress:alignment', + }); + + controller.init(); + + const walletId = MOCK_PREPOPULATED_WALLET_ID; + expect( + controller.state.accountTree.wallets[walletId]?.status, + ).toBe('in-progress:alignment'); + }); + + it('falls back to uninitialized for wallet when the service has no record yet', () => { + const { controller, mocks } = setup({ + accounts: [MOCK_HD_ACCOUNT_1], + keyrings: [MOCK_HD_KEYRING_1], + }); + + mocks.MultichainAccountService.getMultichainAccountWallet.mockImplementation( + () => { + throw new Error('Wallet not found'); + }, + ); + + controller.init(); + + const walletId = MOCK_PREPOPULATED_WALLET_ID; + expect( + controller.state.accountTree.wallets[walletId]?.status, + ).toBe('uninitialized'); + }); + it('reads group status from the service when it is already initialized', () => { const { controller, mocks } = setup({ accounts: [MOCK_HD_ACCOUNT_1], diff --git a/packages/account-tree-controller/src/AccountTreeController.ts b/packages/account-tree-controller/src/AccountTreeController.ts index b5d5d8be99..9904e6ca97 100644 --- a/packages/account-tree-controller/src/AccountTreeController.ts +++ b/packages/account-tree-controller/src/AccountTreeController.ts @@ -1156,7 +1156,10 @@ export class AccountTreeController extends BaseController< log(`[${walletId}] Added as new wallet`); wallets[walletId] = { ...result.wallet, - status: 'ready', + status: + result.wallet.type === AccountWalletType.Entropy + ? this.#queryWalletStatus(result.wallet.metadata.entropy.id) + : 'ready', groups: {}, metadata: { name: '', // Will get updated later. @@ -1464,6 +1467,25 @@ export class AccountTreeController extends BaseController< }); } + /** + * Gets the multichain account wallet's current status from the service. + * Falls back to `'uninitialized'` when the service has no record for the + * wallet yet (e.g. the service hasn't finished its own `init` call). + * + * @param entropySource - The entropy source ID of the wallet. + * @returns The wallet's current status, or `'uninitialized'` if unknown. + */ + #queryWalletStatus(entropySource: string): MultichainAccountWalletStatus { + try { + return this.messenger.call( + 'MultichainAccountService:getMultichainAccountWallet', + { entropySource }, + ).status; + } catch { + return 'uninitialized'; + } + } + /** * Gets the multichain account group's current status from the service. * Falls back to `'uninitialized'` when the service has no record for the diff --git a/packages/account-tree-controller/src/types.ts b/packages/account-tree-controller/src/types.ts index 0d8826c5f1..ca38b2ad7a 100644 --- a/packages/account-tree-controller/src/types.ts +++ b/packages/account-tree-controller/src/types.ts @@ -20,6 +20,7 @@ import type { MultichainAccountServiceCreateMultichainAccountGroupAction, MultichainAccountServiceCreateMultichainAccountGroupsAction, MultichainAccountServiceGetMultichainAccountGroupAction, + MultichainAccountServiceGetMultichainAccountWalletAction, } from '@metamask/multichain-account-service'; import type { MultichainAccountServiceGroupStatusChangeEvent, @@ -99,7 +100,8 @@ export type AllowedActions = | AuthenticationController.AuthenticationControllerGetSessionProfileAction | MultichainAccountServiceCreateMultichainAccountGroupAction | MultichainAccountServiceCreateMultichainAccountGroupsAction - | MultichainAccountServiceGetMultichainAccountGroupAction; + | MultichainAccountServiceGetMultichainAccountGroupAction + | MultichainAccountServiceGetMultichainAccountWalletAction; export type AccountTreeControllerActions = | AccountTreeControllerGetStateAction diff --git a/packages/account-tree-controller/tests/mockMessenger.ts b/packages/account-tree-controller/tests/mockMessenger.ts index a40e3d67fa..07d48f7981 100644 --- a/packages/account-tree-controller/tests/mockMessenger.ts +++ b/packages/account-tree-controller/tests/mockMessenger.ts @@ -63,6 +63,7 @@ export function getAccountTreeControllerMessenger( 'UserStorageController:performBatchSetStorage', 'AuthenticationController:getSessionProfile', 'MultichainAccountService:getMultichainAccountGroup', + 'MultichainAccountService:getMultichainAccountWallet', 'MultichainAccountService:createMultichainAccountGroup', 'KeyringController:getState', 'SnapController:getSnap', From b0d27d0c80a3eac3dc4aa0a6bdfc798f70ebb304 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Mon, 15 Jun 2026 15:14:42 +0200 Subject: [PATCH 7/9] refactor: add checks when inserting status --- .../src/AccountTreeController.ts | 21 +++++++++--------- packages/account-tree-controller/src/group.ts | 22 +++++++++++++++++-- packages/account-tree-controller/src/index.ts | 7 +++++- .../account-tree-controller/src/wallet.ts | 22 +++++++++++++++++-- 4 files changed, 57 insertions(+), 15 deletions(-) diff --git a/packages/account-tree-controller/src/AccountTreeController.ts b/packages/account-tree-controller/src/AccountTreeController.ts index 9904e6ca97..72fa517052 100644 --- a/packages/account-tree-controller/src/AccountTreeController.ts +++ b/packages/account-tree-controller/src/AccountTreeController.ts @@ -32,6 +32,7 @@ import { ACCOUNT_TYPE_TO_SORT_ORDER, isAccountGroupNameUnique, isAccountGroupNameUniqueFromWallet, + isMultichainAccountGroup, MAX_SORT_ORDER, } from './group'; import { projectLogger as log } from './logger'; @@ -46,6 +47,7 @@ import type { AccountTreeControllerState, } from './types'; import type { AccountWalletObject, AccountWalletObjectOf } from './wallet'; +import { isMultichainAccountWallet } from './wallet'; export const controllerName = 'AccountTreeController'; @@ -1156,10 +1158,9 @@ export class AccountTreeController extends BaseController< log(`[${walletId}] Added as new wallet`); wallets[walletId] = { ...result.wallet, - status: - result.wallet.type === AccountWalletType.Entropy - ? this.#queryWalletStatus(result.wallet.metadata.entropy.id) - : 'ready', + status: isMultichainAccountWallet(result.wallet) + ? this.#getMultichainAccountWalletStatus(result.wallet.metadata.entropy.id) + : 'ready', groups: {}, metadata: { name: '', // Will get updated later. @@ -1171,7 +1172,7 @@ export class AccountTreeController extends BaseController< wallet = wallets[walletId]; // Trigger atomic sync for new wallet (only for entropy wallets) - if (wallet.type === AccountWalletType.Entropy) { + if (isMultichainAccountWallet(wallet)) { this.#backupAndSyncService.enqueueSingleWalletSync(walletId); } } @@ -1188,8 +1189,8 @@ export class AccountTreeController extends BaseController< ...result.group, // Type-wise, we are guaranteed to always have at least 1 account. accounts: [id], - ...(result.group.type === AccountGroupType.MultichainAccount && - result.wallet.type === AccountWalletType.Entropy && { + ...(isMultichainAccountGroup(result.group) && + isMultichainAccountWallet(result.wallet) && { status: this.#getMultichainAccountGroupStatus( result.wallet.metadata.entropy.id, result.group.metadata.entropy.groupIndex, @@ -1209,7 +1210,7 @@ export class AccountTreeController extends BaseController< this.#groupIdToWalletId.set(groupId, walletId); // Trigger atomic sync for new group (only for entropy wallets) - if (wallet.type === AccountWalletType.Entropy) { + if (isMultichainAccountWallet(wallet)) { this.#backupAndSyncService.enqueueSingleGroupSync(groupId); } } else { @@ -1460,7 +1461,7 @@ export class AccountTreeController extends BaseController< const walletId = this.#groupIdToWalletId.get(groupId); if (walletId) { const group = state.accountTree.wallets[walletId]?.groups[groupId]; - if (group?.type === AccountGroupType.MultichainAccount) { + if (group && isMultichainAccountGroup(group)) { group.status = groupStatus; } } @@ -1475,7 +1476,7 @@ export class AccountTreeController extends BaseController< * @param entropySource - The entropy source ID of the wallet. * @returns The wallet's current status, or `'uninitialized'` if unknown. */ - #queryWalletStatus(entropySource: string): MultichainAccountWalletStatus { + #getMultichainAccountWalletStatus(entropySource: string): MultichainAccountWalletStatus { try { return this.messenger.call( 'MultichainAccountService:getMultichainAccountWallet', diff --git a/packages/account-tree-controller/src/group.ts b/packages/account-tree-controller/src/group.ts index e108032e7f..fc15a03136 100644 --- a/packages/account-tree-controller/src/group.ts +++ b/packages/account-tree-controller/src/group.ts @@ -1,8 +1,10 @@ -import type { +import { AccountGroupType, +} from '@metamask/account-api'; +import type { MultichainAccountGroupId, + AccountGroupId, } from '@metamask/account-api'; -import type { AccountGroupId } from '@metamask/account-api'; import type { AccountId } from '@metamask/accounts-controller'; import { AnyAccountType, @@ -144,6 +146,22 @@ export function isAccountGroupNameUniqueFromWallet( return true; } +/** + * Returns `true` if the group is a multichain account group, narrowing its + * type to the multichain account variant. + * + * Works for both {@link AccountGroupObject} state objects and the intermediate + * rule-result group shapes used inside `#insert()`. + * + * @param group - The group to check. + * @returns `true` if the group is a multichain account group, `false` otherwise. + */ +export function isMultichainAccountGroup( + group: Value, +): group is Value & { type: AccountGroupType.MultichainAccount } { + return group.type === AccountGroupType.MultichainAccount; +} + /** * Checks if an account group name is unique within the same wallet. * diff --git a/packages/account-tree-controller/src/index.ts b/packages/account-tree-controller/src/index.ts index 28b2f17f64..491be847fe 100644 --- a/packages/account-tree-controller/src/index.ts +++ b/packages/account-tree-controller/src/index.ts @@ -1,6 +1,11 @@ export type { AccountWalletObject } from './wallet'; +export { isMultichainAccountWallet, assertIsMultichainAccountWallet } from './wallet'; export type { AccountGroupObject } from './group'; -export { isAccountGroupNameUnique } from './group'; +export { + isAccountGroupNameUnique, + isMultichainAccountGroup, + assertIsMultichainAccountGroup, +} from './group'; export { USER_STORAGE_GROUPS_FEATURE_KEY, diff --git a/packages/account-tree-controller/src/wallet.ts b/packages/account-tree-controller/src/wallet.ts index fdfc46270f..af27d4d67e 100644 --- a/packages/account-tree-controller/src/wallet.ts +++ b/packages/account-tree-controller/src/wallet.ts @@ -1,11 +1,13 @@ import type { AccountGroupId } from '@metamask/account-api'; -import type { +import { AccountWalletType, +} from '@metamask/account-api'; +import type { AccountWalletId, MultichainAccountWalletId, AccountWalletStatus, + MultichainAccountWalletStatus, } from '@metamask/account-api'; -import type { MultichainAccountWalletStatus } from '@metamask/account-api'; import type { EntropySourceId } from '@metamask/keyring-api'; import type { KeyringTypes } from '@metamask/keyring-controller'; import type { SnapId } from '@metamask/snaps-sdk'; @@ -120,3 +122,19 @@ export type AccountWalletObjectOf = | { type: AccountWalletType.Snap; object: AccountWalletSnapObject }, { type: WalletType } >['object']; + +/** + * Returns `true` if the wallet is a multichain account (entropy-backed) wallet, + * narrowing its type to the entropy variant. + * + * Works for both {@link AccountWalletObject} state objects and the intermediate + * rule-result wallet shapes used inside `#insert()`. + * + * @param wallet - The wallet to check. + * @returns `true` if the wallet is a multichain account wallet, `false` otherwise. + */ +export function isMultichainAccountWallet( + wallet: Value, +): wallet is Value & { type: AccountWalletType.Entropy } { + return wallet.type === AccountWalletType.Entropy; +} \ No newline at end of file From dfa288c3209f7e76f4b1d2725c0d3020678bc4e6 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Mon, 15 Jun 2026 16:41:47 +0200 Subject: [PATCH 8/9] chore: lint --- .../src/AccountTreeController.test.ts | 24 +++++++++++-------- .../src/AccountTreeController.ts | 10 +++++--- packages/account-tree-controller/src/group.ts | 10 ++++---- packages/account-tree-controller/src/index.ts | 5 +++- .../account-tree-controller/src/wallet.ts | 12 ++++------ 5 files changed, 34 insertions(+), 27 deletions(-) diff --git a/packages/account-tree-controller/src/AccountTreeController.test.ts b/packages/account-tree-controller/src/AccountTreeController.test.ts index 2e6284e643..93c5ee8f6e 100644 --- a/packages/account-tree-controller/src/AccountTreeController.test.ts +++ b/packages/account-tree-controller/src/AccountTreeController.test.ts @@ -377,7 +377,9 @@ function setup({ }), // Default: service is initialized and wallet is ready (the common case). // Individual tests can override via mockReturnValue to test other statuses. - getMultichainAccountWallet: jest.fn().mockReturnValue({ status: 'ready' }), + getMultichainAccountWallet: jest + .fn() + .mockReturnValue({ status: 'ready' }), }, AccountsController: { accounts, @@ -1061,16 +1063,18 @@ describe('AccountTreeController', () => { keyrings: [MOCK_HD_KEYRING_1], }); - mocks.MultichainAccountService.getMultichainAccountWallet.mockReturnValue({ - status: 'in-progress:alignment', - }); + mocks.MultichainAccountService.getMultichainAccountWallet.mockReturnValue( + { + status: 'in-progress:alignment', + }, + ); controller.init(); const walletId = MOCK_PREPOPULATED_WALLET_ID; - expect( - controller.state.accountTree.wallets[walletId]?.status, - ).toBe('in-progress:alignment'); + expect(controller.state.accountTree.wallets[walletId]?.status).toBe( + 'in-progress:alignment', + ); }); it('falls back to uninitialized for wallet when the service has no record yet', () => { @@ -1088,9 +1092,9 @@ describe('AccountTreeController', () => { controller.init(); const walletId = MOCK_PREPOPULATED_WALLET_ID; - expect( - controller.state.accountTree.wallets[walletId]?.status, - ).toBe('uninitialized'); + expect(controller.state.accountTree.wallets[walletId]?.status).toBe( + 'uninitialized', + ); }); it('reads group status from the service when it is already initialized', () => { diff --git a/packages/account-tree-controller/src/AccountTreeController.ts b/packages/account-tree-controller/src/AccountTreeController.ts index 72fa517052..20a37c584a 100644 --- a/packages/account-tree-controller/src/AccountTreeController.ts +++ b/packages/account-tree-controller/src/AccountTreeController.ts @@ -1159,7 +1159,9 @@ export class AccountTreeController extends BaseController< wallets[walletId] = { ...result.wallet, status: isMultichainAccountWallet(result.wallet) - ? this.#getMultichainAccountWalletStatus(result.wallet.metadata.entropy.id) + ? this.#getMultichainAccountWalletStatus( + result.wallet.metadata.entropy.id, + ) : 'ready', groups: {}, metadata: { @@ -1189,7 +1191,7 @@ export class AccountTreeController extends BaseController< ...result.group, // Type-wise, we are guaranteed to always have at least 1 account. accounts: [id], - ...(isMultichainAccountGroup(result.group) && + ...(isMultichainAccountGroup(result.group) && isMultichainAccountWallet(result.wallet) && { status: this.#getMultichainAccountGroupStatus( result.wallet.metadata.entropy.id, @@ -1476,7 +1478,9 @@ export class AccountTreeController extends BaseController< * @param entropySource - The entropy source ID of the wallet. * @returns The wallet's current status, or `'uninitialized'` if unknown. */ - #getMultichainAccountWalletStatus(entropySource: string): MultichainAccountWalletStatus { + #getMultichainAccountWalletStatus( + entropySource: string, + ): MultichainAccountWalletStatus { try { return this.messenger.call( 'MultichainAccountService:getMultichainAccountWallet', diff --git a/packages/account-tree-controller/src/group.ts b/packages/account-tree-controller/src/group.ts index fc15a03136..2f10f0d7f8 100644 --- a/packages/account-tree-controller/src/group.ts +++ b/packages/account-tree-controller/src/group.ts @@ -1,6 +1,4 @@ -import { - AccountGroupType, -} from '@metamask/account-api'; +import { AccountGroupType } from '@metamask/account-api'; import type { MultichainAccountGroupId, AccountGroupId, @@ -156,9 +154,9 @@ export function isAccountGroupNameUniqueFromWallet( * @param group - The group to check. * @returns `true` if the group is a multichain account group, `false` otherwise. */ -export function isMultichainAccountGroup( - group: Value, -): group is Value & { type: AccountGroupType.MultichainAccount } { +export function isMultichainAccountGroup< + Value extends { type: AccountGroupType }, +>(group: Value): group is Value & { type: AccountGroupType.MultichainAccount } { return group.type === AccountGroupType.MultichainAccount; } diff --git a/packages/account-tree-controller/src/index.ts b/packages/account-tree-controller/src/index.ts index 491be847fe..9e95ff4113 100644 --- a/packages/account-tree-controller/src/index.ts +++ b/packages/account-tree-controller/src/index.ts @@ -1,5 +1,8 @@ export type { AccountWalletObject } from './wallet'; -export { isMultichainAccountWallet, assertIsMultichainAccountWallet } from './wallet'; +export { + isMultichainAccountWallet, + assertIsMultichainAccountWallet, +} from './wallet'; export type { AccountGroupObject } from './group'; export { isAccountGroupNameUnique, diff --git a/packages/account-tree-controller/src/wallet.ts b/packages/account-tree-controller/src/wallet.ts index af27d4d67e..0ef294b876 100644 --- a/packages/account-tree-controller/src/wallet.ts +++ b/packages/account-tree-controller/src/wallet.ts @@ -1,7 +1,5 @@ import type { AccountGroupId } from '@metamask/account-api'; -import { - AccountWalletType, -} from '@metamask/account-api'; +import { AccountWalletType } from '@metamask/account-api'; import type { AccountWalletId, MultichainAccountWalletId, @@ -133,8 +131,8 @@ export type AccountWalletObjectOf = * @param wallet - The wallet to check. * @returns `true` if the wallet is a multichain account wallet, `false` otherwise. */ -export function isMultichainAccountWallet( - wallet: Value, -): wallet is Value & { type: AccountWalletType.Entropy } { +export function isMultichainAccountWallet< + Value extends { type: AccountWalletType }, +>(wallet: Value): wallet is Value & { type: AccountWalletType.Entropy } { return wallet.type === AccountWalletType.Entropy; -} \ No newline at end of file +} From 711e1aa33d6ca3be58abe3515bb993c221523378 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Mon, 15 Jun 2026 17:29:10 +0200 Subject: [PATCH 9/9] chore: lint --- eslint-suppressions.json | 3 --- .../src/AccountTreeController.test.ts | 5 +++++ 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 4b9a2704a4..138e2e7406 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -3,9 +3,6 @@ "@typescript-eslint/explicit-function-return-type": { "count": 4 }, - "@typescript-eslint/naming-convention": { - "count": 4 - }, "id-length": { "count": 2 } diff --git a/packages/account-tree-controller/src/AccountTreeController.test.ts b/packages/account-tree-controller/src/AccountTreeController.test.ts index 93c5ee8f6e..8a331897ed 100644 --- a/packages/account-tree-controller/src/AccountTreeController.test.ts +++ b/packages/account-tree-controller/src/AccountTreeController.test.ts @@ -338,20 +338,24 @@ function setup({ consoleWarn: jest.SpyInstance; }; mocks: { + // eslint-disable-next-line @typescript-eslint/naming-convention KeyringController: { keyrings: KeyringObject[]; getState: jest.Mock; }; + // eslint-disable-next-line @typescript-eslint/naming-convention MultichainAccountService: { getMultichainAccountGroup: jest.Mock; getMultichainAccountWallet: jest.Mock; }; + // eslint-disable-next-line @typescript-eslint/naming-convention AccountsController: { accounts: InternalAccount[]; listMultichainAccounts: jest.Mock; getSelectedMultichainAccount: jest.Mock; getAccount: jest.Mock; }; + // eslint-disable-next-line @typescript-eslint/naming-convention UserStorageController: { performGetStorage: jest.Mock; performGetStorageAllFeatureEntries: jest.Mock; @@ -359,6 +363,7 @@ function setup({ performBatchSetStorage: jest.Mock; syncInternalAccountsWithUserStorage: jest.Mock; }; + // eslint-disable-next-line @typescript-eslint/naming-convention AuthenticationController: { getSessionProfile: jest.Mock; };