diff --git a/packages/multichain-account-service/src/MultichainAccountGroup.test.ts b/packages/multichain-account-service/src/MultichainAccountGroup.test.ts index 927b6d6e76..408232bc35 100644 --- a/packages/multichain-account-service/src/MultichainAccountGroup.test.ts +++ b/packages/multichain-account-service/src/MultichainAccountGroup.test.ts @@ -25,7 +25,7 @@ import { } from './tests'; import type { MultichainAccountServiceMessenger } from './types'; -function setup({ +async function setup({ groupIndex = 0, messenger = getRootMessenger(), accounts = [ @@ -41,12 +41,12 @@ function setup({ groupIndex?: number; messenger?: RootMessenger; accounts?: InternalAccount[][]; -} = {}): { +} = {}): Promise<{ wallet: MultichainAccountWallet>; group: MultichainAccountGroup>; providers: MockAccountProvider[]; messenger: MultichainAccountServiceMessenger; -} { +}> { const providers = accounts.map((providerAccounts, idx) => { return setupBip44AccountProvider({ name: `Provider ${idx + 1}`, @@ -79,7 +79,7 @@ function setup({ return state; }, {}); - group.init(groupState); + await group.init(groupState); return { wallet, group, providers, messenger: serviceMessenger }; } @@ -92,7 +92,7 @@ describe('MultichainAccountGroup', () => { [MOCK_WALLET_1_SOL_ACCOUNT], ]; const groupIndex = 0; - const { wallet, group } = setup({ groupIndex, accounts }); + const { wallet, group } = await setup({ groupIndex, accounts }); const expectedWalletId = toMultichainAccountWalletId( wallet.entropySource, @@ -115,7 +115,7 @@ describe('MultichainAccountGroup', () => { it('constructs a multichain account group for a specific index', async () => { const groupIndex = 2; - const { group } = setup({ groupIndex }); + const { group } = await setup({ groupIndex }); expect(group.groupIndex).toBe(groupIndex); }); @@ -125,36 +125,36 @@ describe('MultichainAccountGroup', () => { it('gets internal account from its id', async () => { const evmAccount = MOCK_WALLET_1_EVM_ACCOUNT; const solAccount = MOCK_WALLET_1_SOL_ACCOUNT; - const { group } = setup({ accounts: [[evmAccount], [solAccount]] }); + const { group } = await setup({ accounts: [[evmAccount], [solAccount]] }); expect(group.getAccount(evmAccount.id)).toBe(evmAccount); expect(group.getAccount(solAccount.id)).toBe(solAccount); }); it('returns undefined if the account ID does not belong to the multichain account group', async () => { - const { group } = setup(); + const { group } = await setup(); expect(group.getAccount('unknown-id')).toBeUndefined(); }); }); describe('get', () => { - it('gets one account using a selector', () => { - const { group } = setup({ accounts: [[MOCK_WALLET_1_EVM_ACCOUNT]] }); + it('gets one account using a selector', async () => { + const { group } = await setup({ accounts: [[MOCK_WALLET_1_EVM_ACCOUNT]] }); expect(group.get({ scopes: [EthScope.Mainnet] })).toBe( MOCK_WALLET_1_EVM_ACCOUNT, ); }); - it('gets no account if selector did not match', () => { - const { group } = setup({ accounts: [[MOCK_WALLET_1_EVM_ACCOUNT]] }); + it('gets no account if selector did not match', async () => { + const { group } = await setup({ accounts: [[MOCK_WALLET_1_EVM_ACCOUNT]] }); expect(group.get({ scopes: [SolScope.Mainnet] })).toBeUndefined(); }); - it('throws if too many accounts are matching selector', () => { - const { group } = setup({ + it('throws if too many accounts are matching selector', async () => { + const { group } = await setup({ accounts: [[MOCK_WALLET_1_EVM_ACCOUNT, MOCK_WALLET_1_EVM_ACCOUNT]], }); @@ -165,32 +165,135 @@ describe('MultichainAccountGroup', () => { }); describe('select', () => { - it('selects accounts using a selector', () => { - const { group } = setup(); + it('selects accounts using a selector', async () => { + const { group } = await setup(); expect(group.select({ scopes: [EthScope.Mainnet] })).toStrictEqual([ MOCK_WALLET_1_EVM_ACCOUNT, ]); }); - it('selects no account if selector did not match', () => { - const { group } = setup({ accounts: [[MOCK_WALLET_1_EVM_ACCOUNT]] }); + it('selects no account if selector did not match', async () => { + const { group } = await setup({ accounts: [[MOCK_WALLET_1_EVM_ACCOUNT]] }); expect(group.select({ scopes: [SolScope.Mainnet] })).toStrictEqual([]); }); }); + describe('status', () => { + it('is aligned when every provider has at least one account in the group', async () => { + const { group } = await setup({ + accounts: [[MOCK_WALLET_1_EVM_ACCOUNT], [MOCK_WALLET_1_SOL_ACCOUNT]], + }); + + expect(group.status).toBe('aligned'); + }); + + it('is missing-accounts when at least one provider has no accounts in the group', async () => { + const { group } = await setup({ + accounts: [ + [MOCK_WALLET_1_EVM_ACCOUNT], + [], // second provider has no accounts for this group + ], + }); + + expect(group.status).toBe('missing-accounts'); + }); + + it('does not publish a status change event during init', async () => { + const messenger = getRootMessenger(); + const mockStatusChange = jest.fn(); + messenger.subscribe( + 'MultichainAccountService:multichainAccountGroupStatusChange', + mockStatusChange, + ); + + await setup({ messenger }); + + expect(mockStatusChange).not.toHaveBeenCalled(); + }); + + it('publishes a status change event when update transitions the group to aligned', async () => { + const messenger = getRootMessenger(); + const providers = [ + setupBip44AccountProvider({ + name: 'Provider 1', + accounts: [MOCK_WALLET_1_EVM_ACCOUNT], + index: 0, + }), + setupBip44AccountProvider({ + name: 'Provider 2', + accounts: [MOCK_WALLET_1_SOL_ACCOUNT], + index: 1, + }), + ]; + const serviceMessenger = getMultichainAccountServiceMessenger(messenger); + const wallet = new MultichainAccountWallet({ + entropySource: MOCK_WALLET_1_ENTROPY_SOURCE, + messenger: serviceMessenger, + providers, + }); + const group = new MultichainAccountGroup({ + wallet, + groupIndex: 0, + providers, + messenger: serviceMessenger, + }); + + await group.init({ + [providers[0].getName()]: [MOCK_WALLET_1_EVM_ACCOUNT.id], + }); + + expect(group.status).toBe('missing-accounts'); + + const mockStatusChange = jest.fn(); + serviceMessenger.subscribe( + 'MultichainAccountService:multichainAccountGroupStatusChange', + mockStatusChange, + ); + + await group.update({ + [providers[0].getName()]: [MOCK_WALLET_1_EVM_ACCOUNT.id], + [providers[1].getName()]: [MOCK_WALLET_1_SOL_ACCOUNT.id], + }); + + expect(group.status).toBe('aligned'); + expect(mockStatusChange).toHaveBeenCalledTimes(1); + expect(mockStatusChange).toHaveBeenCalledWith(group.id, 'aligned'); + }); + + it('does not publish a status change event when update does not change status', async () => { + const { group, providers, messenger } = await setup({ + accounts: [[MOCK_WALLET_1_EVM_ACCOUNT], [MOCK_WALLET_1_SOL_ACCOUNT]], + }); + + const mockStatusChange = jest.fn(); + messenger.subscribe( + 'MultichainAccountService:multichainAccountGroupStatusChange', + mockStatusChange, + ); + + await group.update({ + [providers[0].getName()]: [MOCK_WALLET_1_EVM_ACCOUNT.id], + [providers[1].getName()]: [MOCK_WALLET_1_SOL_ACCOUNT.id], + }); + + expect(group.status).toBe('aligned'); + expect(mockStatusChange).not.toHaveBeenCalled(); + }); + }); + describe('isAligned', () => { - it('returns true when every provider has at least one account in the group', () => { - const { group } = setup({ + it('returns true when every provider has at least one account in the group', async () => { + const { group } = await setup({ accounts: [[MOCK_WALLET_1_EVM_ACCOUNT], [MOCK_WALLET_1_SOL_ACCOUNT]], }); expect(group.isAligned()).toBe(true); }); - it('returns false when at least one provider has no accounts in the group', () => { - const { group } = setup({ + it('returns false when at least one provider has no accounts in the group', async () => { + const { group } = await setup({ accounts: [ [MOCK_WALLET_1_EVM_ACCOUNT], [], // second provider has no accounts for this group @@ -200,14 +303,14 @@ describe('MultichainAccountGroup', () => { expect(group.isAligned()).toBe(false); }); - it('returns true for a group with no providers', () => { - const { group } = setup({ accounts: [] }); + it('returns true for a group with no providers', async () => { + const { group } = await setup({ accounts: [] }); expect(group.isAligned()).toBe(true); }); - it('returns true when a provider mock is configured to return true despite having no accounts (simulates disabled wrapper)', () => { - const { group, providers } = setup({ + it('returns true when a provider mock is configured to return true despite having no accounts (simulates disabled wrapper)', async () => { + const { group, providers } = await setup({ accounts: [ [MOCK_WALLET_1_EVM_ACCOUNT], [], // second provider has no accounts diff --git a/packages/multichain-account-service/src/MultichainAccountGroup.ts b/packages/multichain-account-service/src/MultichainAccountGroup.ts index 8b7af73e08..326b42591e 100644 --- a/packages/multichain-account-service/src/MultichainAccountGroup.ts +++ b/packages/multichain-account-service/src/MultichainAccountGroup.ts @@ -7,13 +7,17 @@ import type { import type { Bip44Account } from '@metamask/account-api'; import type { AccountSelector } from '@metamask/account-api'; import type { KeyringAccount } from '@metamask/keyring-api'; +import { Mutex } from 'async-mutex'; import type { Logger } from './logger'; 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']]; @@ -24,6 +28,8 @@ export type GroupState = export class MultichainAccountGroup< Account extends Bip44Account, > implements MultichainAccountGroupDefinition { + readonly #lock = new Mutex(); + readonly #id: MultichainAccountGroupId; readonly #wallet: MultichainAccountWallet; @@ -48,6 +54,8 @@ export class MultichainAccountGroup< #initialized = false; + #status: MultichainAccountGroupStatus = 'missing-accounts'; + constructor({ groupIndex, wallet, @@ -83,6 +91,53 @@ export class MultichainAccountGroup< }); } + /** + * Run a mutable operation while holding the group mutex. + * + * @param operation - Operation to run. + * @returns The operation's result. + */ + async #withLock( + operation: () => Return | Promise, + ): Promise { + const release = await this.#lock.acquire(); + try { + this.#log('Locking group...'); + return await operation(); + } finally { + release(); + this.#log('Releasing group lock'); + } + } + + /** + * Derive the group status from the current provider alignment and optionally + * publish a status change event. + * + * @param options - Options. + * @param options.emitEvent - Whether to publish a status change event when + * the status changes. Defaults to `false`. + */ + #syncStatus({ emitEvent = false }: { emitEvent?: boolean } = {}): void { + const nextStatus: MultichainAccountGroupStatus = this.isAligned() + ? 'aligned' + : 'missing-accounts'; + + if (nextStatus === this.#status) { + return; + } + + this.#status = nextStatus; + + if (emitEvent && this.#initialized) { + this.#messenger.publish( + 'MultichainAccountService:multichainAccountGroupStatusChange', + this.id, + this.#status, + ); + } + } + /** * Update the internal representation of accounts with the given group state. * @@ -106,32 +161,42 @@ export class MultichainAccountGroup< /** * Initialize the multichain account group and construct the internal representation of accounts. * + * NOTE: This operation WILL lock the group's mutex. + * * @param groupState - The group state. */ - init(groupState: GroupState): void { - this.#log('Initializing group state...'); - this.#setState(groupState); - this.#log('Finished initializing group state...'); - - this.#initialized = true; + async init(groupState: GroupState): Promise { + await this.#withLock(async () => { + this.#log('Initializing group state...'); + this.#setState(groupState); + this.#syncStatus(); + this.#log('Finished initializing group state...'); + + this.#initialized = true; + }); } /** * Update the group state. * + * NOTE: This operation WILL lock the group's mutex. + * * @param groupState - The group state. */ - update(groupState: GroupState): void { - this.#log('Updating group state...'); - this.#setState(groupState); - this.#log('Finished updating group state...'); - - if (this.#initialized) { - this.#messenger.publish( - 'MultichainAccountService:multichainAccountGroupUpdated', - this, - ); - } + async update(groupState: GroupState): Promise { + await this.#withLock(async () => { + this.#log('Updating group state...'); + this.#setState(groupState); + this.#syncStatus({ emitEvent: true }); + this.#log('Finished updating group state...'); + + if (this.#initialized) { + this.#messenger.publish( + 'MultichainAccountService:multichainAccountGroupUpdated', + this, + ); + } + }); } /** @@ -170,6 +235,15 @@ export class MultichainAccountGroup< return this.#groupIndex; } + /** + * Gets the multichain account group current status. + * + * @returns The multichain account group current status. + */ + get status(): MultichainAccountGroupStatus { + return this.#status; + } + /** * Checks if there's any underlying accounts for this multichain accounts. * diff --git a/packages/multichain-account-service/src/MultichainAccountService.ts b/packages/multichain-account-service/src/MultichainAccountService.ts index c3eb45eb33..af7b8467b7 100644 --- a/packages/multichain-account-service/src/MultichainAccountService.ts +++ b/packages/multichain-account-service/src/MultichainAccountService.ts @@ -310,7 +310,7 @@ export class MultichainAccountService { messenger: this.#messenger, trace: this.#trace, }); - wallet.init(serviceState[entropySource]); + await wallet.init(serviceState[entropySource]); this.#wallets.set(wallet.id, wallet); } @@ -542,7 +542,7 @@ export class MultichainAccountService { assert(wallet, 'Failed to create wallet.'); - wallet.init({}); + await wallet.init({}); // READ THIS CAREFULLY: // We do not await for non-EVM account creations as they // are depending on the Snap platform to be ready (which is, waiting for onboarding to be completed). diff --git a/packages/multichain-account-service/src/MultichainAccountWallet.test.ts b/packages/multichain-account-service/src/MultichainAccountWallet.test.ts index 92c0eeec16..45b4e457b6 100644 --- a/packages/multichain-account-service/src/MultichainAccountWallet.test.ts +++ b/packages/multichain-account-service/src/MultichainAccountWallet.test.ts @@ -38,7 +38,7 @@ import { } from './tests'; import type { MultichainAccountServiceMessenger } from './types'; -function setup({ +async function setup({ entropySource = MOCK_WALLET_1_ENTROPY_SOURCE, messenger = getRootMessenger(), providers, @@ -56,11 +56,11 @@ function setup({ messenger?: RootMessenger; providers?: MockAccountProvider[]; accounts?: InternalAccount[][]; -} = {}): { +} = {}): Promise<{ wallet: MultichainAccountWallet>; providers: MockAccountProvider[]; messenger: MultichainAccountServiceMessenger; -} { +}> { const providersList = providers ?? accounts.map((providerAccounts, i) => { @@ -100,7 +100,7 @@ function setup({ {}, ); - wallet.init(walletState); + await wallet.init(walletState); return { wallet, providers: providersList, messenger: serviceMessenger }; } @@ -124,9 +124,9 @@ describe('MultichainAccountWallet', () => { }); describe('constructor', () => { - it('constructs a multichain account wallet', () => { + it('constructs a multichain account wallet', async () => { const entropySource = MOCK_WALLET_1_ENTROPY_SOURCE; - const { wallet } = setup({ + const { wallet } = await setup({ entropySource, }); @@ -140,8 +140,8 @@ describe('MultichainAccountWallet', () => { }); describe('getMultichainAccountGroup', () => { - it('gets a multichain account group from its index', () => { - const { wallet } = setup(); + it('gets a multichain account group from its index', async () => { + const { wallet } = await setup(); const groupIndex = 0; const multichainAccountGroup = @@ -159,24 +159,24 @@ describe('MultichainAccountWallet', () => { }); describe('getAccountGroup', () => { - it('gets the default multichain account group', () => { - const { wallet } = setup(); + it('gets the default multichain account group', async () => { + const { wallet } = await setup(); const group = wallet.getAccountGroup(toDefaultAccountGroupId(wallet.id)); expect(group).toBeDefined(); expect(group?.id).toBe(toMultichainAccountGroupId(wallet.id, 0)); }); - it('gets a multichain account group when using a multichain account group id', () => { - const { wallet } = setup(); + it('gets a multichain account group when using a multichain account group id', async () => { + const { wallet } = await setup(); const group = wallet.getAccountGroup(toDefaultAccountGroupId(wallet.id)); expect(group).toBeDefined(); expect(group?.id).toBe(toMultichainAccountGroupId(wallet.id, 0)); }); - it('returns undefined when using a bad multichain account group id', () => { - const { wallet } = setup(); + it('returns undefined when using a bad multichain account group id', async () => { + const { wallet } = await setup(); const group = wallet.getAccountGroup(toAccountGroupId(wallet.id, 'bad')); expect(group).toBeUndefined(); @@ -187,7 +187,7 @@ describe('MultichainAccountWallet', () => { it('creates a multichain account group for a given index (waitForAllProvidersToFinishCreatingAccounts = false)', async () => { const groupIndex = 0; - const { wallet, providers } = setup({ + const { wallet, providers } = await setup({ accounts: [[], []], // 2 providers: EVM + SOL }); @@ -220,7 +220,7 @@ describe('MultichainAccountWallet', () => { }); it('returns the same reference when re-creating using the same index (waitForAllProvidersToFinishCreatingAccounts = false)', async () => { - const { wallet } = setup({ + const { wallet } = await setup({ accounts: [[MOCK_HD_ACCOUNT_1]], }); @@ -231,7 +231,7 @@ describe('MultichainAccountWallet', () => { }); it('fails to create an account beyond the next index (waitForAllProvidersToFinishCreatingAccounts = false)', async () => { - const { wallet } = setup({ + const { wallet } = await setup({ accounts: [[MOCK_HD_ACCOUNT_1]], }); @@ -256,7 +256,7 @@ describe('MultichainAccountWallet', () => { .withGroupIndex(0) .get(); - const { wallet, providers } = setup({ + const { wallet, providers } = await setup({ accounts: [[mockEvmAccount], [mockSolAccount]], // 2 providers }); @@ -288,7 +288,7 @@ describe('MultichainAccountWallet', () => { it('captures an error when a provider fails to create its account', async () => { const groupIndex = 1; - const { wallet, providers, messenger } = setup({ + const { wallet, providers, messenger } = await setup({ accounts: [[MOCK_HD_ACCOUNT_1]], }); const [provider] = providers; @@ -311,7 +311,7 @@ describe('MultichainAccountWallet', () => { it('does not capture exception when a provider times out creating accounts', async () => { const groupIndex = 1; - const { wallet, providers, messenger } = setup({ + const { wallet, providers, messenger } = await setup({ accounts: [[MOCK_HD_ACCOUNT_1]], }); const [provider] = providers; @@ -343,7 +343,7 @@ describe('MultichainAccountWallet', () => { .withGroupIndex(groupIndex) .get(); - const { wallet, providers } = setup({ + const { wallet, providers } = await setup({ accounts: [[mockEvmAccount0], []], // EVM has group 0, SOL has none }); @@ -376,7 +376,7 @@ describe('MultichainAccountWallet', () => { .withGroupIndex(0) .get(); - const { wallet, providers } = setup({ + const { wallet, providers } = await setup({ accounts: [ [mockEvmAccount], // EVM provider. [mockSolAccount], // Solana provider. @@ -425,7 +425,7 @@ describe('MultichainAccountWallet', () => { .withGroupIndex(0) .get(); - const { wallet, providers } = setup({ + const { wallet, providers } = await setup({ accounts: [ [mockEvmAccount], // EVM provider. [mockSolAccount], // Solana provider. @@ -468,7 +468,7 @@ describe('MultichainAccountWallet', () => { describe('createMultichainAccountGroups', () => { it('creates multiple groups from 0 to maxGroupIndex when no groups exist (waitForAllProvidersToFinishCreatingAccounts = false)', async () => { - const { wallet, providers } = setup({ + const { wallet, providers } = await setup({ accounts: [[], []], }); @@ -524,7 +524,7 @@ describe('MultichainAccountWallet', () => { .withGroupIndex(0) .get(); - const { wallet, providers } = setup({ + const { wallet, providers } = await setup({ accounts: [[mockEvmAccount0], [mockSolAccount0]], }); @@ -584,7 +584,7 @@ describe('MultichainAccountWallet', () => { .withGroupIndex(2) .get(); - const { wallet } = setup({ + const { wallet } = await setup({ accounts: [[mockEvmAccount0, mockEvmAccount1, mockEvmAccount2]], }); @@ -599,7 +599,7 @@ describe('MultichainAccountWallet', () => { }); it('throws when maxGroupIndex is negative', async () => { - const { wallet } = setup({ + const { wallet } = await setup({ accounts: [[]], }); @@ -610,7 +610,7 @@ describe('MultichainAccountWallet', () => { }); it('captures an error with batch mode message when EVM provider fails', async () => { - const { wallet, providers, messenger } = setup({ + const { wallet, providers, messenger } = await setup({ accounts: [[]], }); @@ -636,7 +636,7 @@ describe('MultichainAccountWallet', () => { }); it('does not capture exception when a provider times out creating accounts in batch', async () => { - const { wallet, providers, messenger } = setup({ + const { wallet, providers, messenger } = await setup({ accounts: [[]], }); @@ -661,7 +661,7 @@ describe('MultichainAccountWallet', () => { }); it('creates accounts for all providers synchronously when waitForAllProvidersToFinishCreatingAccounts is true', async () => { - const { wallet, providers } = setup({ + const { wallet, providers } = await setup({ accounts: [[], []], }); @@ -700,7 +700,7 @@ describe('MultichainAccountWallet', () => { }); it('defers non-EVM account creation to alignment after group creation (waitForAllProvidersToFinishCreatingAccounts = false)', async () => { - const { wallet, providers } = setup({ + const { wallet, providers } = await setup({ accounts: [[], []], }); @@ -736,7 +736,7 @@ describe('MultichainAccountWallet', () => { .get(); // Wallet with groups 0 and 2, gap at 1. - const { wallet, providers } = setup({ + const { wallet, providers } = await setup({ accounts: [[account0, account2]], }); @@ -774,7 +774,7 @@ describe('MultichainAccountWallet', () => { }); it('does not throw if a group cannot be created if it has no accounts', async () => { - const { wallet, providers } = setup({ + const { wallet, providers } = await setup({ accounts: [[]], }); @@ -800,7 +800,7 @@ describe('MultichainAccountWallet', () => { it('logs an error to console when post-alignment fails unexpectedly', async () => { // Group 0 exists for EVM; SOL has no accounts yet (will be aligned). - const { wallet, providers, messenger } = setup({ + const { wallet, providers, messenger } = await setup({ accounts: [[MOCK_WALLET_1_EVM_ACCOUNT], []], }); @@ -859,7 +859,7 @@ describe('MultichainAccountWallet', () => { .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) .withGroupIndex(0) .get(); - const { wallet, providers, messenger } = setup({ + const { wallet, providers, messenger } = await setup({ accounts: [[mockEvmAccount1, mockEvmAccount2], [mockSolAccount]], }); @@ -902,7 +902,7 @@ describe('MultichainAccountWallet', () => { .withUuid() .get(); - const { wallet, providers } = setup({ + const { wallet, providers } = await setup({ accounts: [[mockEvmAccount], []], // SOL provider has no accounts yet }); @@ -925,7 +925,7 @@ describe('MultichainAccountWallet', () => { .withGroupIndex(0) .get(); - const { wallet, providers } = setup({ + const { wallet, providers } = await setup({ accounts: [[mockEvmAccount], []], // EVM + SOL }); @@ -945,7 +945,7 @@ describe('MultichainAccountWallet', () => { }); it('is a no-op when the wallet is already aligned', async () => { - const { wallet, providers } = setup({ + const { wallet, providers } = await setup({ accounts: [[MOCK_WALLET_1_EVM_ACCOUNT], [MOCK_WALLET_1_SOL_ACCOUNT]], }); @@ -966,7 +966,7 @@ describe('MultichainAccountWallet', () => { .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) .withGroupIndex(1) .get(); - const { wallet, providers, messenger } = setup({ + const { wallet, providers, messenger } = await setup({ accounts: [[mockEvmAccount], [mockSolAccount]], }); @@ -1005,7 +1005,7 @@ describe('MultichainAccountWallet', () => { }); it('is a no-op when the group is already aligned', async () => { - const { wallet, providers } = setup({ + const { wallet, providers } = await setup({ accounts: [[MOCK_WALLET_1_EVM_ACCOUNT], [MOCK_WALLET_1_SOL_ACCOUNT]], }); @@ -1017,16 +1017,16 @@ describe('MultichainAccountWallet', () => { }); describe('isAligned', () => { - it('returns true when all groups are aligned', () => { - const { wallet } = setup({ + it('returns true when all groups are aligned', async () => { + const { wallet } = await setup({ accounts: [[MOCK_WALLET_1_EVM_ACCOUNT], [MOCK_WALLET_1_SOL_ACCOUNT]], }); expect(wallet.isAligned()).toBe(true); }); - it('returns false when at least one group is not aligned', () => { - const { wallet } = setup({ + it('returns false when at least one group is not aligned', async () => { + const { wallet } = await setup({ accounts: [ [MOCK_WALLET_1_EVM_ACCOUNT], [], // second provider has no accounts, so the group is not aligned @@ -1036,7 +1036,7 @@ describe('MultichainAccountWallet', () => { expect(wallet.isAligned()).toBe(false); }); - it('returns true for a wallet with no groups', () => { + it('returns true for a wallet with no groups', async () => { const serviceMessenger = getMultichainAccountServiceMessenger(getRootMessenger()); const wallet = new MultichainAccountWallet>( @@ -1046,7 +1046,7 @@ describe('MultichainAccountWallet', () => { messenger: serviceMessenger, }, ); - wallet.init({}); + await wallet.init({}); expect(wallet.isAligned()).toBe(true); }); @@ -1054,7 +1054,7 @@ describe('MultichainAccountWallet', () => { describe('discoverAccounts', () => { it('runs discovery', async () => { - const { wallet, providers, messenger } = setup({ + const { wallet, providers, messenger } = await setup({ accounts: [[], []], }); @@ -1090,7 +1090,7 @@ describe('MultichainAccountWallet', () => { }); it('fast-forwards lagging providers to the highest group index', async () => { - const { wallet, providers } = setup({ + const { wallet, providers } = await setup({ accounts: [[], []], }); @@ -1133,7 +1133,7 @@ describe('MultichainAccountWallet', () => { }); it('stops scheduling a provider when it returns no accounts', async () => { - const { wallet, providers } = setup({ + const { wallet, providers } = await setup({ accounts: [[MOCK_HD_ACCOUNT_1], []], }); @@ -1157,7 +1157,7 @@ describe('MultichainAccountWallet', () => { }); it('marks a provider stopped on error and does not reschedule it', async () => { - const { wallet, providers } = setup({ + const { wallet, providers } = await setup({ accounts: [[], []], }); @@ -1192,7 +1192,7 @@ describe('MultichainAccountWallet', () => { }); it('captures an error when a provider fails to discover its accounts', async () => { - const { wallet, providers, messenger } = setup({ + const { wallet, providers, messenger } = await setup({ accounts: [[], []], }); const providerError = new Error('Unable to discover accounts'); @@ -1213,7 +1213,7 @@ describe('MultichainAccountWallet', () => { }); it('does not capture exception when a provider times out during account discovery', async () => { - const { wallet, providers, messenger } = setup({ + const { wallet, providers, messenger } = await setup({ accounts: [[], []], }); providers[0].discoverAccounts.mockRejectedValueOnce( diff --git a/packages/multichain-account-service/src/MultichainAccountWallet.ts b/packages/multichain-account-service/src/MultichainAccountWallet.ts index 731fa3f777..2ad842e5a7 100644 --- a/packages/multichain-account-service/src/MultichainAccountWallet.ts +++ b/packages/multichain-account-service/src/MultichainAccountWallet.ts @@ -113,7 +113,7 @@ export class MultichainAccountWallet< * * @param walletState - The wallet state. */ - init(walletState: WalletState): void { + async init(walletState: WalletState): Promise { this.#log('Initializing wallet state...'); for (const [groupIndexString, groupState] of Object.entries(walletState)) { // Have to convert to number because the state keys become strings when we construct the state object in the service @@ -127,7 +127,7 @@ export class MultichainAccountWallet< this.#log(`Creating new group for index ${groupIndex}...`); - group.init(groupState); + await group.init(groupState); this.#accountGroups.set(groupIndex, group); } @@ -270,14 +270,14 @@ export class MultichainAccountWallet< * @param groupState The group's state to create or update the group with. * @returns The created or updated multichain account group. */ - #createOrUpdateMultichainAccountGroup( + async #createOrUpdateMultichainAccountGroup( groupIndex: number, groupState: GroupState, - ): MultichainAccountGroup { + ): Promise> { let group = this.#accountGroups.get(groupIndex); if (group) { // NOTE: This will publish an update event automatically. - group.update(groupState); + await group.update(groupState); this.#log(`Group updated: [${group.id}]`); } else { @@ -287,7 +287,7 @@ export class MultichainAccountWallet< groupIndex, messenger: this.#messenger, }); - group.init(groupState); + await group.init(groupState); this.#accountGroups.set(groupIndex, group); @@ -412,7 +412,7 @@ export class MultichainAccountWallet< const groupState = groupStateByGroupIndex.get(groupIndex); if (groupState) { - const group = this.#createOrUpdateMultichainAccountGroup( + const group = await this.#createOrUpdateMultichainAccountGroup( groupIndex, groupState, ); @@ -475,7 +475,10 @@ export class MultichainAccountWallet< for (let groupIndex = from; groupIndex <= to; groupIndex++) { const groupState = groupStateByGroupIndex.get(groupIndex); if (groupState) { - this.#createOrUpdateMultichainAccountGroup(groupIndex, groupState); + await this.#createOrUpdateMultichainAccountGroup( + groupIndex, + groupState, + ); } } }, @@ -922,7 +925,7 @@ export class MultichainAccountWallet< groupIndex, messenger: this.#messenger, }); - group.init(groupState); + await group.init(groupState); this.#accountGroups.set(groupIndex, group); } diff --git a/packages/multichain-account-service/src/index.ts b/packages/multichain-account-service/src/index.ts index 2c6dec78e8..9e1b8b11dd 100644 --- a/packages/multichain-account-service/src/index.ts +++ b/packages/multichain-account-service/src/index.ts @@ -3,9 +3,11 @@ export type { MultichainAccountServiceEvents, MultichainAccountServiceMessenger, MultichainAccountServiceMultichainAccountGroupCreatedEvent, + MultichainAccountServiceMultichainAccountGroupStatusChangeEvent, MultichainAccountServiceMultichainAccountGroupUpdatedEvent, MultichainAccountServiceWalletStatusChangeEvent, } from './types'; +export type { MultichainAccountGroupStatus } from './types'; export type { MultichainAccountServiceResyncAccountsAction, MultichainAccountServiceGetMultichainAccountWalletAction, diff --git a/packages/multichain-account-service/src/types.ts b/packages/multichain-account-service/src/types.ts index b640f49757..3e5796163d 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'; @@ -52,11 +53,24 @@ export type MultichainAccountServiceMultichainAccountGroupUpdatedEvent = { payload: [MultichainAccountGroup>]; }; +/** + * Multichain account group status. + * + * Reports whether the group has accounts from every enabled account provider + * (EVM, Solana, Bitcoin, and Tron). + */ +export type MultichainAccountGroupStatus = 'aligned' | 'missing-accounts'; + export type MultichainAccountServiceWalletStatusChangeEvent = { type: `${typeof serviceName}:walletStatusChange`; payload: [MultichainAccountWalletId, MultichainAccountWalletStatus]; }; +export type MultichainAccountServiceMultichainAccountGroupStatusChangeEvent = { + type: `${typeof serviceName}:multichainAccountGroupStatusChange`; + payload: [MultichainAccountGroupId, MultichainAccountGroupStatus]; +}; + /** * All events that {@link MultichainAccountService} publishes so that other modules * can subscribe to them. @@ -64,6 +78,7 @@ export type MultichainAccountServiceWalletStatusChangeEvent = { export type MultichainAccountServiceEvents = | MultichainAccountServiceMultichainAccountGroupCreatedEvent | MultichainAccountServiceMultichainAccountGroupUpdatedEvent + | MultichainAccountServiceMultichainAccountGroupStatusChangeEvent | MultichainAccountServiceWalletStatusChangeEvent; /**