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/CHANGELOG.md b/packages/account-tree-controller/CHANGELOG.md index 87714fcf70..34bf01129c 100644 --- a/packages/account-tree-controller/CHANGELOG.md +++ b/packages/account-tree-controller/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **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 - 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..8a331897ed 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', @@ -337,16 +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; @@ -354,6 +363,7 @@ function setup({ performBatchSetStorage: jest.Mock; syncInternalAccountsWithUserStorage: jest.Mock; }; + // eslint-disable-next-line @typescript-eslint/naming-convention AuthenticationController: { getSessionProfile: jest.Mock; }; @@ -364,6 +374,18 @@ function setup({ keyrings, getState: jest.fn(), }, + MultichainAccountService: { + // 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, listMultichainAccounts: jest.fn(), @@ -388,6 +410,15 @@ function setup({ }, }; + messenger.registerActionHandler( + 'MultichainAccountService:getMultichainAccountGroup', + mocks.MultichainAccountService.getMultichainAccountGroup, + ); + messenger.registerActionHandler( + 'MultichainAccountService:getMultichainAccountWallet', + mocks.MultichainAccountService.getMultichainAccountWallet, + ); + if (accounts) { mocks.AccountsController.listMultichainAccounts.mockImplementation( () => mocks.AccountsController.accounts, @@ -562,6 +593,7 @@ describe('AccountTreeController', () => { [expectedWalletId1Group]: { id: expectedWalletId1Group, type: AccountGroupType.MultichainAccount, + status: 'uninitialized', accounts: [MOCK_HD_ACCOUNT_1.id], metadata: { name: 'Account 1', @@ -589,6 +621,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 +636,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) @@ -1027,6 +1061,81 @@ describe('AccountTreeController', () => { expect(groupIds[1]).toBe(toMultichainAccountGroupId(walletId, 1)); 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], + 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', () => { @@ -1286,6 +1395,7 @@ describe('AccountTreeController', () => { [walletId1Group]: { id: walletId1Group, type: AccountGroupType.MultichainAccount, + status: 'uninitialized', metadata: { name: 'Account 1', entropy: { @@ -1377,6 +1487,7 @@ describe('AccountTreeController', () => { [walletId1Group2]: { id: walletId1Group2, type: AccountGroupType.MultichainAccount, + status: 'uninitialized', metadata: { name: 'Account 2', entropy: { @@ -1612,6 +1723,7 @@ describe('AccountTreeController', () => { [walletId1Group]: { id: walletId1Group, type: AccountGroupType.MultichainAccount, + status: 'uninitialized', metadata: { name: 'Account 1', entropy: { @@ -1720,6 +1832,7 @@ describe('AccountTreeController', () => { [walletId1Group]: { id: walletId1Group, type: AccountGroupType.MultichainAccount, + status: 'uninitialized', metadata: { name: 'Account 1', entropy: { @@ -1748,6 +1861,7 @@ describe('AccountTreeController', () => { [walletId2Group]: { id: walletId2Group, type: AccountGroupType.MultichainAccount, + status: 'uninitialized', metadata: { name: 'Account 1', // Updated: per-wallet naming (different wallet) entropy: { @@ -1871,6 +1985,57 @@ 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..20a37c584a 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'; @@ -27,6 +32,7 @@ import { ACCOUNT_TYPE_TO_SORT_ORDER, isAccountGroupNameUnique, isAccountGroupNameUniqueFromWallet, + isMultichainAccountGroup, MAX_SORT_ORDER, } from './group'; import { projectLogger as log } from './logger'; @@ -41,6 +47,7 @@ import type { AccountTreeControllerState, } from './types'; import type { AccountWalletObject, AccountWalletObjectOf } from './wallet'; +import { isMultichainAccountWallet } from './wallet'; export const controllerName = 'AccountTreeController'; @@ -277,6 +284,13 @@ export class AccountTreeController extends BaseController< }, ); + this.messenger.subscribe( + 'MultichainAccountService:groupStatusChange', + (groupId, status) => { + this.#handleMultichainAccountGroupStatusChange(groupId, status); + }, + ); + this.messenger.registerMethodActionHandlers( this, MESSENGER_EXPOSED_METHODS, @@ -1144,7 +1158,11 @@ export class AccountTreeController extends BaseController< log(`[${walletId}] Added as new wallet`); wallets[walletId] = { ...result.wallet, - status: 'ready', + status: isMultichainAccountWallet(result.wallet) + ? this.#getMultichainAccountWalletStatus( + result.wallet.metadata.entropy.id, + ) + : 'ready', groups: {}, metadata: { name: '', // Will get updated later. @@ -1156,7 +1174,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); } } @@ -1173,6 +1191,13 @@ export class AccountTreeController extends BaseController< ...result.group, // Type-wise, we are guaranteed to always have at least 1 account. accounts: [id], + ...(isMultichainAccountGroup(result.group) && + isMultichainAccountWallet(result.wallet) && { + status: this.#getMultichainAccountGroupStatus( + result.wallet.metadata.entropy.id, + result.group.metadata.entropy.groupIndex, + ), + }), metadata: { name: '', ...{ pinned: false, hidden: false, lastSelected: 0 }, // Default UI states @@ -1187,7 +1212,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 { @@ -1423,6 +1448,73 @@ 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 && isMultichainAccountGroup(group)) { + group.status = groupStatus; + } + } + }); + } + + /** + * 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. + */ + #getMultichainAccountWalletStatus( + 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 + * 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/group.ts b/packages/account-tree-controller/src/group.ts index 5e53cbc5a0..2f10f0d7f8 100644 --- a/packages/account-tree-controller/src/group.ts +++ b/packages/account-tree-controller/src/group.ts @@ -1,8 +1,8 @@ +import { AccountGroupType } from '@metamask/account-api'; import type { - AccountGroupType, MultichainAccountGroupId, + AccountGroupId, } from '@metamask/account-api'; -import type { AccountGroupId } from '@metamask/account-api'; import type { AccountId } from '@metamask/accounts-controller'; import { AnyAccountType, @@ -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 & { @@ -142,6 +144,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< + Value extends { type: AccountGroupType }, +>(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..9e95ff4113 100644 --- a/packages/account-tree-controller/src/index.ts +++ b/packages/account-tree-controller/src/index.ts @@ -1,6 +1,14 @@ 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/types.ts b/packages/account-tree-controller/src/types.ts index e7a6657139..ca38b2ad7a 100644 --- a/packages/account-tree-controller/src/types.ts +++ b/packages/account-tree-controller/src/types.ts @@ -19,8 +19,13 @@ import type { Messenger } from '@metamask/messenger'; import type { MultichainAccountServiceCreateMultichainAccountGroupAction, MultichainAccountServiceCreateMultichainAccountGroupsAction, + MultichainAccountServiceGetMultichainAccountGroupAction, + MultichainAccountServiceGetMultichainAccountWalletAction, +} from '@metamask/multichain-account-service'; +import type { + MultichainAccountServiceGroupStatusChangeEvent, + MultichainAccountServiceWalletStatusChangeEvent, } from '@metamask/multichain-account-service'; -import type { MultichainAccountServiceWalletStatusChangeEvent } from '@metamask/multichain-account-service'; import type { AuthenticationController, UserStorageController, @@ -94,7 +99,9 @@ export type AllowedActions = | UserStorageController.UserStorageControllerPerformBatchSetStorageAction | AuthenticationController.AuthenticationControllerGetSessionProfileAction | MultichainAccountServiceCreateMultichainAccountGroupAction - | MultichainAccountServiceCreateMultichainAccountGroupsAction; + | MultichainAccountServiceCreateMultichainAccountGroupsAction + | MultichainAccountServiceGetMultichainAccountGroupAction + | MultichainAccountServiceGetMultichainAccountWalletAction; export type AccountTreeControllerActions = | AccountTreeControllerGetStateAction @@ -158,7 +165,8 @@ export type AllowedEvents = | AccountsControllerAccountsRemovedEvent | AccountsControllerSelectedAccountChangeEvent | UserStorageController.UserStorageControllerStateChangeEvent - | MultichainAccountServiceWalletStatusChangeEvent; + | MultichainAccountServiceWalletStatusChangeEvent + | MultichainAccountServiceGroupStatusChangeEvent; export type AccountTreeControllerEvents = | AccountTreeControllerStateChangeEvent diff --git a/packages/account-tree-controller/src/wallet.ts b/packages/account-tree-controller/src/wallet.ts index fdfc46270f..0ef294b876 100644 --- a/packages/account-tree-controller/src/wallet.ts +++ b/packages/account-tree-controller/src/wallet.ts @@ -1,11 +1,11 @@ import type { AccountGroupId } from '@metamask/account-api'; +import { AccountWalletType } from '@metamask/account-api'; import type { - AccountWalletType, 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 +120,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< + Value extends { type: AccountWalletType }, +>(wallet: Value): wallet is Value & { type: AccountWalletType.Entropy } { + return wallet.type === AccountWalletType.Entropy; +} diff --git a/packages/account-tree-controller/tests/mockMessenger.ts b/packages/account-tree-controller/tests/mockMessenger.ts index a96594dbf7..07d48f7981 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', @@ -61,6 +62,8 @@ export function getAccountTreeControllerMessenger( 'UserStorageController:performSetStorage', 'UserStorageController:performBatchSetStorage', 'AuthenticationController:getSessionProfile', + 'MultichainAccountService:getMultichainAccountGroup', + 'MultichainAccountService:getMultichainAccountWallet', 'MultichainAccountService:createMultichainAccountGroup', 'KeyringController:getState', 'SnapController:getSnap', diff --git a/packages/multichain-account-service/CHANGELOG.md b/packages/multichain-account-service/CHANGELOG.md index d859e80892..6bab68b0ba 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 ([#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. ### 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..ca27a7a73f 100644 --- a/packages/multichain-account-service/src/MultichainAccountWallet.ts +++ b/packages/multichain-account-service/src/MultichainAccountWallet.ts @@ -443,12 +443,18 @@ 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, 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 +481,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 +718,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}