diff --git a/packages/accounts-controller/CHANGELOG.md b/packages/accounts-controller/CHANGELOG.md index 19f79cb4f55..6fdfacb59b3 100644 --- a/packages/accounts-controller/CHANGELOG.md +++ b/packages/accounts-controller/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add a `getAccounts` method (and its associated action) that is the plural version of `getAccount` ([#6654](https://github.com/MetaMask/core/pull/6708)) + - This method is added to primarily be consumed in the `MultichainAccountService`. + ### Changed - Bump `@metamask/utils` from `^11.4.2` to `^11.8.1` ([#6588](https://github.com/MetaMask/core/pull/6588), [#6708](https://github.com/MetaMask/core/pull/6708)) diff --git a/packages/accounts-controller/src/AccountsController.test.ts b/packages/accounts-controller/src/AccountsController.test.ts index eb95dcb199a..216e72e24e1 100644 --- a/packages/accounts-controller/src/AccountsController.test.ts +++ b/packages/accounts-controller/src/AccountsController.test.ts @@ -2777,6 +2777,26 @@ describe('AccountsController', () => { }); }); + describe('getAccounts', () => { + it('returns a list of accounts based on the given account IDs', () => { + const { accountsController } = setupAccountsController({ + initialState: { + internalAccounts: { + accounts: { + [mockAccount.id]: mockAccount, + [mockAccount2.id]: mockAccount2, + }, + selectedAccount: mockAccount.id, + }, + }, + }); + + const result = accountsController.getAccounts([mockAccount.id]); + + expect(result).toStrictEqual([mockAccount]); + }); + }); + describe('getSelectedAccount', () => { it.each([ { diff --git a/packages/accounts-controller/src/AccountsController.ts b/packages/accounts-controller/src/AccountsController.ts index 3ca7bee9817..2bd0fb04af9 100644 --- a/packages/accounts-controller/src/AccountsController.ts +++ b/packages/accounts-controller/src/AccountsController.ts @@ -124,6 +124,11 @@ export type AccountsControllerGetAccountAction = { handler: AccountsController['getAccount']; }; +export type AccountsControllerGetAccountsAction = { + type: `${typeof controllerName}:getAccounts`; + handler: AccountsController['getAccounts']; +}; + export type AccountsControllerUpdateAccountMetadataAction = { type: `${typeof controllerName}:updateAccountMetadata`; handler: AccountsController['updateAccountMetadata']; @@ -145,6 +150,7 @@ export type AccountsControllerActions = | AccountsControllerGetSelectedAccountAction | AccountsControllerGetNextAvailableAccountNameAction | AccountsControllerGetAccountAction + | AccountsControllerGetAccountsAction | AccountsControllerGetSelectedMultichainAccountAction | AccountsControllerUpdateAccountMetadataAction; @@ -303,6 +309,16 @@ export class AccountsController extends BaseController< return this.state.internalAccounts.accounts[accountId]; } + /** + * Returns the internal account objects for the given account IDs, if they exist. + * + * @param accountIds - The IDs of the accounts to retrieve. + * @returns The internal account objects, or undefined if the account(s) do not exist. + */ + getAccounts(accountIds: string[]): (InternalAccount | undefined)[] { + return accountIds.map((accountId) => this.getAccount(accountId)); + } + /** * Returns an array of all evm internal accounts. * @@ -1305,6 +1321,11 @@ export class AccountsController extends BaseController< this.getAccount.bind(this), ); + this.messagingSystem.registerActionHandler( + `AccountsController:getAccounts`, + this.getAccounts.bind(this), + ); + this.messagingSystem.registerActionHandler( `AccountsController:updateAccountMetadata`, this.updateAccountMetadata.bind(this), diff --git a/packages/accounts-controller/src/index.ts b/packages/accounts-controller/src/index.ts index 2894b9d6e71..8c75201adcc 100644 --- a/packages/accounts-controller/src/index.ts +++ b/packages/accounts-controller/src/index.ts @@ -13,6 +13,7 @@ export type { AccountsControllerGetAccountByAddressAction, AccountsControllerGetNextAvailableAccountNameAction, AccountsControllerGetAccountAction, + AccountsControllerGetAccountsAction, AccountsControllerUpdateAccountMetadataAction, AllowedActions, AccountsControllerActions, diff --git a/packages/keyring-controller/CHANGELOG.md b/packages/keyring-controller/CHANGELOG.md index 7cc14c7d04f..5d6043655e3 100644 --- a/packages/keyring-controller/CHANGELOG.md +++ b/packages/keyring-controller/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add actions for `createNewVaultAndKeychain` and `createNewVaultAndRestore` ([#6654](https://github.com/MetaMask/core/pull/6708)) + - These actions are meant to to be consumed by the `MultichainAccountService` in its `createMultichainAccountWallet` method. + ### Changed - Bump `@metamask/utils` from `^11.4.2` to `^11.8.1` ([#6588](https://github.com/MetaMask/core/pull/6588), [#6708](https://github.com/MetaMask/core/pull/6708)) diff --git a/packages/keyring-controller/src/KeyringController.ts b/packages/keyring-controller/src/KeyringController.ts index 30cd02020fd..5e4cf83d7ef 100644 --- a/packages/keyring-controller/src/KeyringController.ts +++ b/packages/keyring-controller/src/KeyringController.ts @@ -184,6 +184,16 @@ export type KeyringControllerWithKeyringAction = { handler: KeyringController['withKeyring']; }; +export type KeyringControllerCreateNewVaultAndKeychainAction = { + type: `${typeof name}:createNewVaultAndKeychain`; + handler: KeyringController['createNewVaultAndKeychain']; +}; + +export type KeyringControllerCreateNewVaultAndRestoreAction = { + type: `${typeof name}:createNewVaultAndRestore`; + handler: KeyringController['createNewVaultAndRestore']; +}; + export type KeyringControllerAddNewKeyringAction = { type: `${typeof name}:addNewKeyring`; handler: KeyringController['addNewKeyring']; @@ -226,7 +236,9 @@ export type KeyringControllerActions = | KeyringControllerSignUserOperationAction | KeyringControllerAddNewAccountAction | KeyringControllerWithKeyringAction - | KeyringControllerAddNewKeyringAction; + | KeyringControllerAddNewKeyringAction + | KeyringControllerCreateNewVaultAndKeychainAction + | KeyringControllerCreateNewVaultAndRestoreAction; export type KeyringControllerEvents = | KeyringControllerStateChangeEvent @@ -1780,6 +1792,16 @@ export class KeyringController extends BaseController< `${name}:addNewKeyring`, this.addNewKeyring.bind(this), ); + + this.messagingSystem.registerActionHandler( + `${name}:createNewVaultAndKeychain`, + this.createNewVaultAndKeychain.bind(this), + ); + + this.messagingSystem.registerActionHandler( + `${name}:createNewVaultAndRestore`, + this.createNewVaultAndRestore.bind(this), + ); } /** diff --git a/packages/multichain-account-service/CHANGELOG.md b/packages/multichain-account-service/CHANGELOG.md index a26b1d0de54..2ea3b36ce6c 100644 --- a/packages/multichain-account-service/CHANGELOG.md +++ b/packages/multichain-account-service/CHANGELOG.md @@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **BREAKING** A performance refactor was made around all the classes in this package ([#6654](https://github.com/MetaMask/core/pull/6708)) + - Add logic in the `createMultichainAccountWallet` method in `MultichainAccountService` so that it can handle all entry points: importing an SRP, recovering a vault and creating a new vault. + - Add a `getAccountIds` method which returns all the account ids pertaining to a group. + - Add an `addAccounts` method on the `BaseBip44AccountProvider` class which keeps track of all the account IDs that pertain to it. + +### Changed + +- **BREAKING** A performance refactor was made around all the classes in this package ([#6654](https://github.com/MetaMask/core/pull/6708)) + - The `MultichainAccountService` is refactored to construct a top level service state for its `init` function, this state is passed down to the `MultichainAccountWallet` and `MultichainAccountGroup` classes in slices for them to construct their internal states. + - Additional state is generated at the entry points where it needs to be updated i.e. `createMultichainAccountGroup`, `discoverAccounts` and `alignAccounts`. + - We no longer prevent group creation if some providers' `createAccounts` calls fail during group creation, only if they all fail. + - The `getAccounts` method in the `BaseBip44AccountProvider` class no longer relies on fetching the entire list of internal accounts from the `AccountsController`, instead it gets the specific accounts that it stores in its internal accounts list. + - The `EvmAccountProvider` no longer fetches from the `AccountController` to get an account for its ID, we deterministically get the associated account ID through `getUUIDFromAddressOfNormalAccount`. + - The `EvmAccountProvider` now uses the `getAccount` method from the `AccountsController` when fetching an account after account creation as it is more efficient. + +### Removed + +- **BREAKING** A performance refactor was made around all the classes in this package ([#6654](https://github.com/MetaMask/core/pull/6708)) + - Remove `#handleOnAccountAdded` and `#handleOnAccountRemoved` methods in `MultichainAccountService` due to internal state being updated within the service. + - Remove `getAccountContext` (and associated map) in the `MultichainAccountService` as the service no longer uses that method. + - Remove the `sync` method in favor of the sole `init` method for both `MultichainAccountWallet` and `MultichainAccountGroup`. + ## [1.6.0] ### Changed diff --git a/packages/multichain-account-service/src/MultichainAccountGroup.test.ts b/packages/multichain-account-service/src/MultichainAccountGroup.test.ts index ae915804aaa..108a903fa15 100644 --- a/packages/multichain-account-service/src/MultichainAccountGroup.test.ts +++ b/packages/multichain-account-service/src/MultichainAccountGroup.test.ts @@ -9,8 +9,12 @@ import type { Messenger } from '@metamask/base-controller'; import { EthScope, SolScope } from '@metamask/keyring-api'; import type { InternalAccount } from '@metamask/keyring-internal-api'; -import { MultichainAccountGroup } from './MultichainAccountGroup'; +import { + type GroupState, + MultichainAccountGroup, +} from './MultichainAccountGroup'; import { MultichainAccountWallet } from './MultichainAccountWallet'; +import type { BaseBip44AccountProvider } from './providers'; import type { MockAccountProvider } from './tests'; import { MOCK_SNAP_ACCOUNT_2, @@ -54,23 +58,40 @@ function setup({ group: MultichainAccountGroup>; providers: MockAccountProvider[]; } { - const providers = accounts.map((providerAccounts) => { - return setupNamedAccountProvider({ accounts: providerAccounts }); + const providers = accounts.map((providerAccounts, idx) => { + return setupNamedAccountProvider({ + name: `Provider ${idx + 1}`, + accounts: providerAccounts, + }); }); const wallet = new MultichainAccountWallet>({ entropySource: MOCK_WALLET_1_ENTROPY_SOURCE, messenger: getMultichainAccountServiceMessenger(messenger), - providers, + providers: providers as unknown as BaseBip44AccountProvider[], }); const group = new MultichainAccountGroup({ wallet, groupIndex, - providers, + providers: providers as unknown as BaseBip44AccountProvider[], messenger: getMultichainAccountServiceMessenger(messenger), }); + // Initialize group state from provided accounts so that constructor tests + // observe accounts immediately + const groupState = providers.reduce((state, provider, idx) => { + const ids = accounts[idx] + .filter((a) => 'options' in a && a.options?.entropy) + .map((a) => a.id); + if (ids.length > 0) { + state[provider.getName()] = ids; + } + return state; + }, {}); + + group.init(groupState); + return { wallet, group, providers }; } @@ -95,6 +116,10 @@ describe('MultichainAccount', () => { expect(group.type).toBe(AccountGroupType.MultichainAccount); expect(group.groupIndex).toBe(groupIndex); expect(group.wallet).toStrictEqual(wallet); + expect(group.hasAccounts()).toBe(true); + expect(group.getAccountIds()).toStrictEqual( + expectedAccounts.map((a) => a.id), + ); expect(group.getAccounts()).toHaveLength(expectedAccounts.length); expect(group.getAccounts()).toStrictEqual(expectedAccounts); }); @@ -177,6 +202,10 @@ describe('MultichainAccount', () => { ], }); + providers[1].createAccounts.mockResolvedValueOnce([ + MOCK_WALLET_1_SOL_ACCOUNT, + ]); + await group.alignAccounts(); expect(providers[0].createAccounts).not.toHaveBeenCalled(); @@ -199,7 +228,7 @@ describe('MultichainAccount', () => { expect(providers[1].createAccounts).not.toHaveBeenCalled(); }); - it('warns if provider alignment fails', async () => { + it('warns if alignment fails for a single provider', async () => { const groupIndex = 0; const { group, providers, wallet } = setup({ groupIndex, @@ -219,7 +248,39 @@ describe('MultichainAccount', () => { groupIndex, }); expect(consoleSpy).toHaveBeenCalledWith( - `Failed to fully align multichain account group for entropy ID: ${wallet.entropySource} and group index: ${groupIndex}, some accounts might be missing`, + `Failed to fully align multichain account group for entropy ID: ${wallet.entropySource} and group index: ${groupIndex}, some accounts might be missing. Provider threw the following error:\n- Provider 2: Unable to create accounts`, + ); + }); + + it('warns if alignment fails for multiple providers', async () => { + const groupIndex = 0; + const { group, providers, wallet } = setup({ + groupIndex, + accounts: [[MOCK_WALLET_1_EVM_ACCOUNT], [], []], + }); + + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + providers[1].createAccounts.mockRejectedValueOnce( + new Error('Unable to create accounts'), + ); + + providers[2].createAccounts.mockRejectedValueOnce( + new Error('Unable to create accounts'), + ); + + await group.alignAccounts(); + + expect(providers[0].createAccounts).not.toHaveBeenCalled(); + expect(providers[1].createAccounts).toHaveBeenCalledWith({ + entropySource: wallet.entropySource, + groupIndex, + }); + expect(providers[2].createAccounts).toHaveBeenCalledWith({ + entropySource: wallet.entropySource, + groupIndex, + }); + expect(consoleSpy).toHaveBeenCalledWith( + `Failed to fully align multichain account group for entropy ID: ${wallet.entropySource} and group index: ${groupIndex}, some accounts might be missing. Providers threw the following errors:\n- Provider 2: Unable to create accounts\n- Provider 3: Unable to create accounts`, ); }); }); diff --git a/packages/multichain-account-service/src/MultichainAccountGroup.ts b/packages/multichain-account-service/src/MultichainAccountGroup.ts index baf7cfc854f..9b026a219ef 100644 --- a/packages/multichain-account-service/src/MultichainAccountGroup.ts +++ b/packages/multichain-account-service/src/MultichainAccountGroup.ts @@ -14,10 +14,14 @@ import { createModuleLogger, WARNING_PREFIX, } from './logger'; +import type { ServiceState, StateKeys } from './MultichainAccountService'; import type { MultichainAccountWallet } from './MultichainAccountWallet'; -import type { NamedAccountProvider } from './providers'; +import type { BaseBip44AccountProvider } from './providers'; import type { MultichainAccountServiceMessenger } from './types'; +export type GroupState = + ServiceState[StateKeys['entropySource']][StateKeys['groupIndex']]; + /** * A multichain account group that holds multiple accounts. */ @@ -31,23 +35,16 @@ export class MultichainAccountGroup< readonly #groupIndex: number; - readonly #providers: NamedAccountProvider[]; + readonly #providers: BaseBip44AccountProvider[]; - readonly #providerToAccounts: Map< - NamedAccountProvider, - Account['id'][] - >; + readonly #providerToAccounts: Map; - readonly #accountToProvider: Map< - Account['id'], - NamedAccountProvider - >; + readonly #accountToProvider: Map; readonly #messenger: MultichainAccountServiceMessenger; readonly #log: Logger; - // eslint-disable-next-line @typescript-eslint/prefer-readonly #initialized = false; constructor({ @@ -58,7 +55,7 @@ export class MultichainAccountGroup< }: { groupIndex: number; wallet: MultichainAccountWallet; - providers: NamedAccountProvider[]; + providers: BaseBip44AccountProvider[]; messenger: MultichainAccountServiceMessenger; }) { this.#id = toMultichainAccountGroupId(wallet.id, groupIndex); @@ -70,52 +67,41 @@ export class MultichainAccountGroup< this.#accountToProvider = new Map(); this.#log = createModuleLogger(log, `[${this.#id}]`); - - this.sync(); - this.#initialized = true; } /** - * Force multichain account synchronization. + * Initialize the multichain account group and construct the internal representation of accounts. + * + * Note: This method can be called multiple times to update the group state. * - * This can be used if account providers got new accounts that the multichain - * account doesn't know about. + * @param groupState - The group state. */ - sync(): void { - this.#log('Synchronizing with account providers...'); - // Clear reverse mapping and re-construct it entirely based on the refreshed - // list of accounts from each providers. - this.#accountToProvider.clear(); - + init(groupState: GroupState) { + this.#log('Initializing group state...'); for (const provider of this.#providers) { - // Filter account only for that index. - const accounts = []; - for (const account of provider.getAccounts()) { - if ( - account.options.entropy.id === this.wallet.entropySource && - account.options.entropy.groupIndex === this.groupIndex - ) { - // We only use IDs to always fetch the latest version of accounts. - accounts.push(account.id); - } - } - this.#providerToAccounts.set(provider, accounts); + const accountIds = groupState[provider.getName()]; - // Reverse-mapping for fast indexing. - for (const id of accounts) { - this.#accountToProvider.set(id, provider); + if (accountIds) { + this.#providerToAccounts.set(provider, accountIds); + // Add the accounts to the provider's internal list of account IDs + provider.addAccounts(accountIds); + + for (const accountId of accountIds) { + this.#accountToProvider.set(accountId, provider); + } } } - // Emit update event when group is synced (only if initialized) - if (this.#initialized) { + if (!this.#initialized) { + this.#initialized = true; + } else { this.#messenger.publish( 'MultichainAccountService:multichainAccountGroupUpdated', this, ); } - this.#log('Synchronized'); + this.#log('Finished initializing group state...'); } /** @@ -180,7 +166,8 @@ export class MultichainAccountGroup< // If for some reason we cannot get this account from the provider, it // might means it has been deleted or something, so we just filter it // out. - allAccounts.push(account); + // We cast here because TS cannot infer the type of the account from the provider + allAccounts.push(account as Account); } } } @@ -188,6 +175,15 @@ export class MultichainAccountGroup< return allAccounts; } + /** + * Gets the account IDs for this multichain account. + * + * @returns The account IDs. + */ + getAccountIds(): Account['id'][] { + return [...this.#providerToAccounts.values()].flat(); + } + /** * Gets the account for a given account ID. * @@ -202,7 +198,9 @@ export class MultichainAccountGroup< if (!provider) { return undefined; } - return provider.getAccount(id); + + // We cast here because TS cannot infer the type of the account from the provider + return provider.getAccount(id) as Account; } /** @@ -249,12 +247,33 @@ export class MultichainAccountGroup< return created; } - return Promise.resolve(); + return Promise.reject(new Error('Already aligned')); }), ); - if (results.some((result) => result.status === 'rejected')) { - const message = `Failed to fully align multichain account group for entropy ID: ${this.wallet.entropySource} and group index: ${this.groupIndex}, some accounts might be missing`; + let failureMessage = ''; + let failureCount = 0; + const groupState = results.reduce((state, result, idx) => { + if (result.status === 'fulfilled') { + state[this.#providers[idx].getName()] = result.value.map( + (account) => account.id, + ); + } else if ( + result.status === 'rejected' && + result.reason.message !== 'Already aligned' + ) { + failureCount += 1; + failureMessage += `\n- ${this.#providers[idx].getName()}: ${result.reason.message}`; + } + return state; + }, {}); + + // Update group state + this.init(groupState); + + if (failureCount > 0) { + const hasMultipleFailures = failureCount > 1; + const message = `Failed to fully align multichain account group for entropy ID: ${this.wallet.entropySource} and group index: ${this.groupIndex}, some accounts might be missing. ${hasMultipleFailures ? 'Providers' : 'Provider'} threw the following ${hasMultipleFailures ? 'errors' : 'error'}:${failureMessage}`; this.#log(`${WARNING_PREFIX} ${message}`); console.warn(message); diff --git a/packages/multichain-account-service/src/MultichainAccountService.test.ts b/packages/multichain-account-service/src/MultichainAccountService.test.ts index 1cdac410036..de20f8e0e3e 100644 --- a/packages/multichain-account-service/src/MultichainAccountService.test.ts +++ b/packages/multichain-account-service/src/MultichainAccountService.test.ts @@ -5,9 +5,13 @@ import { mnemonicPhraseToBytes } from '@metamask/key-tree'; import type { KeyringAccount } from '@metamask/keyring-api'; import { EthAccountType, SolAccountType } from '@metamask/keyring-api'; import { KeyringTypes, type KeyringObject } from '@metamask/keyring-controller'; +import type { EthKeyring } from '@metamask/keyring-internal-api'; import type { MultichainAccountServiceOptions } from './MultichainAccountService'; -import { MultichainAccountService } from './MultichainAccountService'; +import { + CreateWalletFlow, + MultichainAccountService, +} from './MultichainAccountService'; import type { NamedAccountProvider } from './providers'; import { AccountProviderWrapper } from './providers/AccountProviderWrapper'; import { @@ -35,7 +39,6 @@ import { getMultichainAccountServiceMessenger, getRootMessenger, makeMockAccountProvider, - mockAsInternalAccount, setupNamedAccountProvider, } from './tests'; import type { @@ -66,6 +69,9 @@ type Mocks = { getState: jest.Mock; getKeyringsByType: jest.Mock; addNewKeyring: jest.Mock; + createNewVaultAndKeychain: jest.Mock; + createNewVaultAndRestore: jest.Mock; + withKeyring: jest.Mock; }; AccountsController: { listMultichainAccounts: jest.Mock; @@ -78,7 +84,6 @@ function mockAccountProvider( providerClass: new (messenger: MultichainAccountServiceMessenger) => Provider, mocks: MockAccountProvider, accounts: KeyringAccount[], - type: KeyringAccount['type'], ) { jest.mocked(providerClass).mockImplementation((...args) => { mocks.constructor(...args); @@ -88,8 +93,20 @@ function mockAccountProvider( setupNamedAccountProvider({ mocks, accounts, - filter: (account) => account.type === type, }); + + // Provide stable provider name and compatibility logic for grouping + if (providerClass === (EvmAccountProvider as unknown)) { + mocks.getName.mockReturnValue(EVM_ACCOUNT_PROVIDER_NAME); + mocks.isAccountCompatible?.mockImplementation( + (account: KeyringAccount) => account.type === EthAccountType.Eoa, + ); + } else if (providerClass === (SolAccountProvider as unknown)) { + mocks.getName.mockReturnValue(SOL_ACCOUNT_PROVIDER_NAME); + mocks.isAccountCompatible?.mockImplementation( + (account: KeyringAccount) => account.type === SolAccountType.DataAccount, + ); + } } function setup({ @@ -120,6 +137,9 @@ function setup({ getState: jest.fn(), getKeyringsByType: jest.fn(), addNewKeyring: jest.fn(), + createNewVaultAndKeychain: jest.fn(), + createNewVaultAndRestore: jest.fn(), + withKeyring: jest.fn(), }, AccountsController: { listMultichainAccounts: jest.fn(), @@ -151,6 +171,16 @@ function setup({ mocks.KeyringController.addNewKeyring, ); + messenger.registerActionHandler( + 'KeyringController:createNewVaultAndKeychain', + mocks.KeyringController.createNewVaultAndKeychain, + ); + + messenger.registerActionHandler( + 'KeyringController:createNewVaultAndRestore', + mocks.KeyringController.createNewVaultAndRestore, + ); + if (accounts) { mocks.AccountsController.listMultichainAccounts.mockImplementation( () => accounts, @@ -170,13 +200,11 @@ function setup({ EvmAccountProvider, mocks.EvmAccountProvider, accounts, - EthAccountType.Eoa, ); mockAccountProvider( SolAccountProvider, mocks.SolAccountProvider, accounts, - SolAccountType.DataAccount, ); } @@ -402,263 +430,7 @@ describe('MultichainAccountService', () => { }); }); - describe('getAccountContext', () => { - const entropy1 = 'entropy-1'; - const entropy2 = 'entropy-2'; - - const account1 = MockAccountBuilder.from(MOCK_HD_ACCOUNT_1) - .withId('mock-id-1') - .withEntropySource(entropy1) - .withGroupIndex(0) - .get(); - const account2 = MockAccountBuilder.from(MOCK_HD_ACCOUNT_1) - .withId('mock-id-2') - .withEntropySource(entropy1) - .withGroupIndex(1) - .get(); - const account3 = MockAccountBuilder.from(MOCK_HD_ACCOUNT_1) - .withId('mock-id-3') - .withEntropySource(entropy2) - .withGroupIndex(0) - .get(); - - const keyring1 = { - type: KeyringTypes.hd, - accounts: [account1.address, account2.address], - metadata: { id: entropy1, name: '' }, - }; - const keyring2 = { - type: KeyringTypes.hd, - accounts: [account2.address], - metadata: { id: entropy2, name: '' }, - }; - - const keyrings: KeyringObject[] = [keyring1, keyring2]; - - it('gets the wallet and multichain account for a given account ID', () => { - const accounts = [account1, account2, account3]; - const { service } = setup({ accounts, keyrings }); - - const wallet1 = service.getMultichainAccountWallet({ - entropySource: entropy1, - }); - const wallet2 = service.getMultichainAccountWallet({ - entropySource: entropy2, - }); - - const [multichainAccount1, multichainAccount2] = - wallet1.getMultichainAccountGroups(); - const [multichainAccount3] = wallet2.getMultichainAccountGroups(); - - const walletAndMultichainAccount1 = service.getAccountContext( - account1.id, - ); - const walletAndMultichainAccount2 = service.getAccountContext( - account2.id, - ); - const walletAndMultichainAccount3 = service.getAccountContext( - account3.id, - ); - - // NOTE: We use `toBe` here, cause we want to make sure we use the same - // references with `get*` service's methods. - expect(walletAndMultichainAccount1?.wallet).toBe(wallet1); - expect(walletAndMultichainAccount1?.group).toBe(multichainAccount1); - - expect(walletAndMultichainAccount2?.wallet).toBe(wallet1); - expect(walletAndMultichainAccount2?.group).toBe(multichainAccount2); - - expect(walletAndMultichainAccount3?.wallet).toBe(wallet2); - expect(walletAndMultichainAccount3?.group).toBe(multichainAccount3); - }); - - it('syncs the appropriate wallet and update reverse mapping on AccountsController:accountAdded', () => { - const accounts = [account1, account3]; // No `account2` for now. - const { service, messenger, mocks } = setup({ accounts, keyrings }); - - const wallet1 = service.getMultichainAccountWallet({ - entropySource: entropy1, - }); - expect(wallet1.getMultichainAccountGroups()).toHaveLength(1); - - // Now we're adding `account2`. - mocks.EvmAccountProvider.accounts = [account1, account2]; - messenger.publish( - 'AccountsController:accountAdded', - mockAsInternalAccount(account2), - ); - expect(wallet1.getMultichainAccountGroups()).toHaveLength(2); - - const [multichainAccount1, multichainAccount2] = - wallet1.getMultichainAccountGroups(); - - const walletAndMultichainAccount1 = service.getAccountContext( - account1.id, - ); - const walletAndMultichainAccount2 = service.getAccountContext( - account2.id, - ); - - // NOTE: We use `toBe` here, cause we want to make sure we use the same - // references with `get*` service's methods. - expect(walletAndMultichainAccount1?.wallet).toBe(wallet1); - expect(walletAndMultichainAccount1?.group).toBe(multichainAccount1); - - expect(walletAndMultichainAccount2?.wallet).toBe(wallet1); - expect(walletAndMultichainAccount2?.group).toBe(multichainAccount2); - }); - - it('syncs the appropriate multichain account and update reverse mapping on AccountsController:accountAdded', () => { - const otherAccount1 = MockAccountBuilder.from(account2) - .withGroupIndex(0) - .get(); - - const accounts = [account1]; // No `otherAccount1` for now. - const { service, messenger, mocks } = setup({ accounts, keyrings }); - - const wallet1 = service.getMultichainAccountWallet({ - entropySource: entropy1, - }); - expect(wallet1.getMultichainAccountGroups()).toHaveLength(1); - - // Now we're adding `account2`. - mocks.EvmAccountProvider.accounts = [account1, otherAccount1]; - messenger.publish( - 'AccountsController:accountAdded', - mockAsInternalAccount(otherAccount1), - ); - // Still 1, that's the same multichain account, but a new "blockchain - // account" got added. - expect(wallet1.getMultichainAccountGroups()).toHaveLength(1); - - const [multichainAccount1] = wallet1.getMultichainAccountGroups(); - - const walletAndMultichainAccount1 = service.getAccountContext( - account1.id, - ); - const walletAndMultichainOtherAccount1 = service.getAccountContext( - otherAccount1.id, - ); - - // NOTE: We use `toBe` here, cause we want to make sure we use the same - // references with `get*` service's methods. - expect(walletAndMultichainAccount1?.wallet).toBe(wallet1); - expect(walletAndMultichainAccount1?.group).toBe(multichainAccount1); - - expect(walletAndMultichainOtherAccount1?.wallet).toBe(wallet1); - expect(walletAndMultichainOtherAccount1?.group).toBe(multichainAccount1); - }); - - it('emits multichainAccountGroupUpdated event when syncing existing group on account added', () => { - const otherAccount1 = MockAccountBuilder.from(account2) - .withGroupIndex(0) - .get(); - - const accounts = [account1]; // No `otherAccount1` for now. - const { messenger, mocks } = setup({ accounts, keyrings }); - const publishSpy = jest.spyOn(messenger, 'publish'); - - // Now we're adding `otherAccount1` to an existing group. - mocks.EvmAccountProvider.accounts = [account1, otherAccount1]; - messenger.publish( - 'AccountsController:accountAdded', - mockAsInternalAccount(otherAccount1), - ); - - // Should emit updated event for the existing group - expect(publishSpy).toHaveBeenCalledWith( - 'MultichainAccountService:multichainAccountGroupUpdated', - expect.any(Object), - ); - }); - - it('creates new detected wallets and update reverse mapping on AccountsController:accountAdded', () => { - const accounts = [account1, account2]; // No `account3` for now (associated with "Wallet 2"). - const { service, messenger, mocks } = setup({ - accounts, - keyrings: [keyring1], - }); - - const wallet1 = service.getMultichainAccountWallet({ - entropySource: entropy1, - }); - expect(wallet1.getMultichainAccountGroups()).toHaveLength(2); - - // No wallet 2 yet. - expect(() => - service.getMultichainAccountWallet({ entropySource: entropy2 }), - ).toThrow('Unknown wallet, no wallet matching this entropy source'); - - // Now we're adding `account3`. - mocks.KeyringController.keyrings = [keyring1, keyring2]; - mocks.EvmAccountProvider.accounts = [account1, account2, account3]; - messenger.publish( - 'AccountsController:accountAdded', - mockAsInternalAccount(account3), - ); - const wallet2 = service.getMultichainAccountWallet({ - entropySource: entropy2, - }); - expect(wallet2).toBeDefined(); - expect(wallet2.getMultichainAccountGroups()).toHaveLength(1); - - const [multichainAccount3] = wallet2.getMultichainAccountGroups(); - - const walletAndMultichainAccount3 = service.getAccountContext( - account3.id, - ); - - // NOTE: We use `toBe` here, cause we want to make sure we use the same - // references with `get*` service's methods. - expect(walletAndMultichainAccount3?.wallet).toBe(wallet2); - expect(walletAndMultichainAccount3?.group).toBe(multichainAccount3); - }); - - it('ignores non-BIP-44 accounts on AccountsController:accountAdded', () => { - const accounts = [account1]; - const { service, messenger } = setup({ accounts, keyrings }); - - const wallet1 = service.getMultichainAccountWallet({ - entropySource: entropy1, - }); - const oldMultichainAccounts = wallet1.getMultichainAccountGroups(); - expect(oldMultichainAccounts).toHaveLength(1); - expect(oldMultichainAccounts[0].getAccounts()).toHaveLength(1); - - // Now we're publishing a new account that is not BIP-44 compatible. - messenger.publish( - 'AccountsController:accountAdded', - mockAsInternalAccount(MOCK_SNAP_ACCOUNT_2), - ); - - const newMultichainAccounts = wallet1.getMultichainAccountGroups(); - expect(newMultichainAccounts).toHaveLength(1); - expect(newMultichainAccounts[0].getAccounts()).toHaveLength(1); - }); - - it('syncs the appropriate wallet and update reverse mapping on AccountsController:accountRemoved', () => { - const accounts = [account1, account2]; - const { service, messenger, mocks } = setup({ accounts, keyrings }); - - const wallet1 = service.getMultichainAccountWallet({ - entropySource: entropy1, - }); - expect(wallet1.getMultichainAccountGroups()).toHaveLength(2); - - // Now we're removing `account2`. - mocks.EvmAccountProvider.accounts = [account1]; - messenger.publish('AccountsController:accountRemoved', account2.id); - expect(wallet1.getMultichainAccountGroups()).toHaveLength(1); - - const walletAndMultichainAccount2 = service.getAccountContext( - account2.id, - ); - - expect(walletAndMultichainAccount2).toBeUndefined(); - }); - }); - - describe('createNextMultichainAccount', () => { + describe('createNextMultichainAccountGroup', () => { it('creates the next multichain account group', async () => { const mockEvmAccount = MockAccountBuilder.from(MOCK_HD_ACCOUNT_1) .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) @@ -946,6 +718,8 @@ describe('MultichainAccountService', () => { it('creates a multichain account wallet with MultichainAccountService:createMultichainAccountWallet', async () => { const { messenger, mocks } = setup({ accounts: [], keyrings: [] }); + const mnemonic = mnemonicPhraseToBytes(MOCK_MNEMONIC); + mocks.KeyringController.getKeyringsByType.mockImplementationOnce( () => [], ); @@ -957,7 +731,7 @@ describe('MultichainAccountService', () => { const wallet = await messenger.call( 'MultichainAccountService:createMultichainAccountWallet', - { mnemonic: MOCK_MNEMONIC }, + { mnemonic, flowType: CreateWalletFlow.Import }, ); expect(wallet).toBeDefined(); @@ -1087,48 +861,143 @@ describe('MultichainAccountService', () => { }); describe('createMultichainAccountWallet', () => { - it('creates a new multichain account wallet with the given mnemonic', async () => { - const { mocks, service } = setup({ - accounts: [], - keyrings: [], + it('throws an error if the create wallet parameters are invalid', async () => { + const { service } = setup({ accounts: [], keyrings: [] }); + await expect(() => + service.createMultichainAccountWallet({ + flowType: CreateWalletFlow.Create, + }), + ).rejects.toThrow('Invalid create wallet parameters.'); + }); + + describe('createWalletByImport', () => { + it('creates a new multichain account wallet by the import flow', async () => { + const { mocks, service } = setup({ + accounts: [], + keyrings: [], + }); + + const mnemonic = mnemonicPhraseToBytes(MOCK_MNEMONIC); + + mocks.KeyringController.getKeyringsByType.mockImplementationOnce(() => [ + {}, + ]); + + mocks.KeyringController.addNewKeyring.mockImplementationOnce(() => ({ + id: 'abc', + name: '', + })); + + const wallet = await service.createMultichainAccountWallet({ + mnemonic, + flowType: CreateWalletFlow.Import, + }); + + expect(wallet).toBeDefined(); + expect(wallet.entropySource).toBe('abc'); }); - mocks.KeyringController.getKeyringsByType.mockImplementationOnce(() => [ - {}, - ]); + it("throws an error if there's already an existing keyring from the same mnemonic", async () => { + const { service, mocks } = setup({ accounts: [], keyrings: [] }); - mocks.KeyringController.addNewKeyring.mockImplementationOnce(() => ({ - id: 'abc', - name: '', - })); + const mnemonic = mnemonicPhraseToBytes(MOCK_MNEMONIC); + + mocks.KeyringController.getKeyringsByType.mockImplementationOnce(() => [ + { + mnemonic, + }, + ]); + + await expect( + service.createMultichainAccountWallet({ + mnemonic, + flowType: CreateWalletFlow.Import, + }), + ).rejects.toThrow( + 'This Secret Recovery Phrase has already been imported.', + ); - const wallet = await service.createMultichainAccountWallet({ - mnemonic: MOCK_MNEMONIC, + // Ensure we did not attempt to create a new keyring when duplicate is detected + expect(mocks.KeyringController.addNewKeyring).not.toHaveBeenCalled(); }); + }); - expect(wallet).toBeDefined(); - expect(wallet.entropySource).toBe('abc'); + describe('createWalletByNewVault', () => { + it('creates a new multichain account wallet by the new vault flow', async () => { + const { service, mocks, messenger } = setup({ + accounts: [], + keyrings: [], + }); + + const password = 'password'; + + mocks.KeyringController.createNewVaultAndKeychain.mockImplementationOnce( + () => { + mocks.KeyringController.keyrings.push(MOCK_HD_KEYRING_1); + }, + ); + + messenger.registerActionHandler( + 'KeyringController:withKeyring', + async (_, operation) => { + const newKeyring = mocks.KeyringController.keyrings.find( + (keyring) => keyring.type === KeyringTypes.hd, + ) as KeyringObject; + return operation({ + keyring: {} as unknown as EthKeyring, + metadata: newKeyring.metadata, + }); + }, + ); + + const newWallet = await service.createMultichainAccountWallet({ + password, + flowType: CreateWalletFlow.Create, + }); + + expect(newWallet).toBeDefined(); + expect(newWallet.entropySource).toBe(MOCK_HD_KEYRING_1.metadata.id); + }); }); - it("throws an error if there's already an existing keyring from the same mnemonic", async () => { - const { service, mocks } = setup({ accounts: [], keyrings: [] }); + describe('createWalletByRestore', () => { + it('creates a new multichain account wallet by the restore flow', async () => { + const { service, mocks, messenger } = setup({ + accounts: [], + keyrings: [], + }); - const mnemonic = mnemonicPhraseToBytes(MOCK_MNEMONIC); + const mnemonic = mnemonicPhraseToBytes(MOCK_MNEMONIC); + const password = 'password'; - mocks.KeyringController.getKeyringsByType.mockImplementationOnce(() => [ - { - mnemonic, - }, - ]); + mocks.KeyringController.createNewVaultAndRestore.mockImplementationOnce( + () => { + mocks.KeyringController.keyrings.push(MOCK_HD_KEYRING_1); + }, + ); + + messenger.registerActionHandler( + 'KeyringController:withKeyring', + async (_, operation) => { + const newKeyring = mocks.KeyringController.keyrings.find( + (keyring) => keyring.type === KeyringTypes.hd, + ) as KeyringObject; + return operation({ + keyring: {} as unknown as EthKeyring, + metadata: newKeyring.metadata, + }); + }, + ); - await expect( - service.createMultichainAccountWallet({ mnemonic: MOCK_MNEMONIC }), - ).rejects.toThrow( - 'This Secret Recovery Phrase has already been imported.', - ); + const newWallet = await service.createMultichainAccountWallet({ + password, + mnemonic, + flowType: CreateWalletFlow.Restore, + }); - // Ensure we did not attempt to create a new keyring when duplicate is detected - expect(mocks.KeyringController.addNewKeyring).not.toHaveBeenCalled(); + expect(newWallet).toBeDefined(); + expect(newWallet.entropySource).toBe(MOCK_HD_KEYRING_1.metadata.id); + }); }); }); }); diff --git a/packages/multichain-account-service/src/MultichainAccountService.ts b/packages/multichain-account-service/src/MultichainAccountService.ts index 776e057b139..5f6cf2e4ada 100644 --- a/packages/multichain-account-service/src/MultichainAccountService.ts +++ b/packages/multichain-account-service/src/MultichainAccountService.ts @@ -7,17 +7,17 @@ import type { Bip44Account, } from '@metamask/account-api'; import type { HdKeyring } from '@metamask/eth-hd-keyring'; -import { mnemonicPhraseToBytes } from '@metamask/key-tree'; import type { EntropySourceId, KeyringAccount } from '@metamask/keyring-api'; import { KeyringTypes } from '@metamask/keyring-controller'; -import { areUint8ArraysEqual } from '@metamask/utils'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import { areUint8ArraysEqual, assert } from '@metamask/utils'; import { projectLogger as log } from './logger'; import type { MultichainAccountGroup } from './MultichainAccountGroup'; import { MultichainAccountWallet } from './MultichainAccountWallet'; import type { + BaseBip44AccountProvider, EvmAccountProviderConfig, - NamedAccountProvider, SolAccountProviderConfig, } from './providers'; import { @@ -35,37 +35,63 @@ export const serviceName = 'MultichainAccountService'; */ export type MultichainAccountServiceOptions = { messenger: MultichainAccountServiceMessenger; - providers?: NamedAccountProvider[]; + providers?: BaseBip44AccountProvider[]; providerConfigs?: { [EvmAccountProvider.NAME]?: EvmAccountProviderConfig; [SolAccountProvider.NAME]?: SolAccountProviderConfig; }; }; -/** Reverse mapping object used to map account IDs and their wallet/multichain account. */ -type AccountContext> = { - wallet: MultichainAccountWallet; - group: MultichainAccountGroup; +/** + * The keys used to identify an account in the service state. + */ +export type StateKeys = { + entropySource: EntropySourceId; + groupIndex: number; + providerName: string; }; +/** + * The service state. + */ +export type ServiceState = { + [entropySource: StateKeys['entropySource']]: { + [groupIndex: string]: { + [ + providerName: StateKeys['providerName'] + ]: Bip44Account['id'][]; + }; + }; +}; + +export enum CreateWalletFlow { + Restore = 'restore', + Import = 'import', + Create = 'create', +} + +type CreateWalletValidatedParams = + | { + flowType: CreateWalletFlow.Restore; + password: string; + mnemonic: Uint8Array; + } + | { flowType: CreateWalletFlow.Import; mnemonic: Uint8Array } + | { flowType: CreateWalletFlow.Create; password: string }; + /** * Service to expose multichain accounts capabilities. */ export class MultichainAccountService { readonly #messenger: MultichainAccountServiceMessenger; - readonly #providers: NamedAccountProvider[]; + readonly #providers: BaseBip44AccountProvider[]; readonly #wallets: Map< MultichainAccountWalletId, MultichainAccountWallet> >; - readonly #accountIdToContext: Map< - Bip44Account['id'], - AccountContext> - >; - /** * The name of the service. */ @@ -88,7 +114,6 @@ export class MultichainAccountService { }: MultichainAccountServiceOptions) { this.#messenger = messenger; this.#wallets = new Map(); - this.#accountIdToContext = new Map(); // TODO: Rely on keyring capabilities once the keyring API is used by all keyrings. this.#providers = [ @@ -147,136 +172,93 @@ export class MultichainAccountService { 'MultichainAccountService:createMultichainAccountWallet', (...args) => this.createMultichainAccountWallet(...args), ); - - this.#messenger.subscribe('AccountsController:accountAdded', (account) => - this.#handleOnAccountAdded(account), - ); - this.#messenger.subscribe('AccountsController:accountRemoved', (id) => - this.#handleOnAccountRemoved(id), - ); } /** - * Initialize the service and constructs the internal reprensentation of - * multichain accounts and wallets. + * Get the keys used to identify an account in the service state. + * + * @param account - The account to get the keys for. + * @returns The keys used to identify an account in the service state. + * Returns null if the account is not compatible with any provider. */ - init(): void { - log('Initializing...'); + #getStateKeys(account: InternalAccount): StateKeys | null { + for (const provider of this.#providers) { + if (isBip44Account(account) && provider.isAccountCompatible(account)) { + return { + entropySource: account.options.entropy.id, + groupIndex: account.options.entropy.groupIndex, + providerName: provider.getName(), + }; + } + } + return null; + } - this.#wallets.clear(); - this.#accountIdToContext.clear(); + /** + * Construct the service state. + * + * @returns The service state. + */ + #constructServiceState() { + const accounts = this.#messenger.call( + 'AccountsController:listMultichainAccounts', + ); - // Create initial wallets. + const serviceState: ServiceState = {}; const { keyrings } = this.#messenger.call('KeyringController:getState'); + + // We set up the wallet level keys for this constructed state object for (const keyring of keyrings) { if (keyring.type === (KeyringTypes.hd as string)) { - // Only HD keyrings have an entropy source/SRP. - const entropySource = keyring.metadata.id; - - log(`Adding new wallet for entropy: "${entropySource}"`); - - // This will automatically "associate" all multichain accounts for that wallet - // (based on the accounts owned by each account providers). - const wallet = new MultichainAccountWallet({ - entropySource, - providers: this.#providers, - messenger: this.#messenger, - }); - this.#wallets.set(wallet.id, wallet); - - // Reverse mapping between account ID and their multichain wallet/account: - for (const group of wallet.getMultichainAccountGroups()) { - for (const account of group.getAccounts()) { - this.#accountIdToContext.set(account.id, { - wallet, - group, - }); - } - } + serviceState[keyring.metadata.id] = {}; } } - log('Initialized'); - } - - #handleOnAccountAdded(account: KeyringAccount): void { - // We completely omit non-BIP-44 accounts! - if (!isBip44Account(account)) { - return; + for (const account of accounts) { + const keys = this.#getStateKeys(account); + if (keys) { + serviceState[keys.entropySource][keys.groupIndex] ??= {}; + serviceState[keys.entropySource][keys.groupIndex][keys.providerName] ??= + []; + serviceState[keys.entropySource][keys.groupIndex][ + keys.providerName + ].push(account.id); + } } - let sync = true; + return serviceState; + } - let wallet = this.#wallets.get( - toMultichainAccountWalletId(account.options.entropy.id), - ); - if (!wallet) { - log( - `Adding new wallet for entropy: "${account.options.entropy.id}" (for account: "${account.id}")`, - ); + /** + * Initialize the service and constructs the internal reprensentation of + * multichain accounts and wallets. + */ + init(): void { + log('Initializing...'); + + this.#wallets.clear(); - // That's a new wallet. - wallet = new MultichainAccountWallet({ - entropySource: account.options.entropy.id, + const serviceState = this.#constructServiceState(); + for (const entropySource of Object.keys(serviceState)) { + const wallet = new MultichainAccountWallet({ + entropySource, providers: this.#providers, messenger: this.#messenger, }); + wallet.init(serviceState[entropySource]); this.#wallets.set(wallet.id, wallet); - - // If that's a new wallet wallet. There's nothing to "force-sync". - sync = false; - } - - let group = wallet.getMultichainAccountGroup( - account.options.entropy.groupIndex, - ); - if (!group) { - // This new account is a new multichain account, let the wallet know - // it has to re-sync with its providers. - if (sync) { - wallet.sync(); - } - - group = wallet.getMultichainAccountGroup( - account.options.entropy.groupIndex, - ); - - // If that's a new multichain account. There's nothing to "force-sync". - sync = false; } - // We have to check against `undefined` in case `getMultichainAccount` is - // not able to find this multichain account (which should not be possible...) - if (group) { - if (sync) { - group.sync(); - } - - // Same here, this account should have been already grouped in that - // multichain account. - this.#accountIdToContext.set(account.id, { - wallet, - group, - }); - } - } - - #handleOnAccountRemoved(id: KeyringAccount['id']): void { - // Force sync of the appropriate wallet if an account got removed. - const found = this.#accountIdToContext.get(id); - if (found) { - const { wallet } = found; - - log( - `Re-synchronize wallet [${wallet.id}] since account "${id}" got removed`, - ); - wallet.sync(); - } - - // Safe to call delete even if the `id` was not referencing a BIP-44 account. - this.#accountIdToContext.delete(id); + log('Initialized'); } + /** + * Get the wallet matching the given entropy source. + * + * @param entropySource - The entropy source of the wallet. + * @returns The wallet matching the given entropy source. + * @throws If no wallet matches the given entropy source. + */ #getWallet( entropySource: EntropySourceId, ): MultichainAccountWallet> { @@ -291,19 +273,6 @@ export class MultichainAccountService { return wallet; } - /** - * Gets the account's context which contains its multichain wallet and - * multichain account group references. - * - * @param id - Account ID. - * @returns The account context if any, undefined otherwise. - */ - getAccountContext( - id: KeyringAccount['id'], - ): AccountContext> | undefined { - return this.#accountIdToContext.get(id); - } - /** * Gets a reference to the multichain account wallet matching this entropy source. * @@ -332,52 +301,193 @@ export class MultichainAccountService { } /** - * Creates a new multichain account wallet with the given mnemonic. - * - * NOTE: This method should only be called in client code where a mutex lock is acquired. - * `discoverAndCreateAccounts` should be called after this method to discover and create accounts. + * Gets the validated create wallet parameters. * * @param options - Options. * @param options.mnemonic - The mnemonic to use to create the new wallet. - * @throws If the mnemonic has already been imported. - * @returns The new multichain account wallet. + * @param options.password - The password to encrypt the vault with. + * @param options.flowType - The flow type to use to create the new wallet. + * @returns The validated create wallet parameters. */ - async createMultichainAccountWallet({ + #getValidatedCreateWalletParams({ mnemonic, + password, + flowType, }: { - mnemonic: string; - }): Promise>> { + mnemonic?: Uint8Array; + password?: string; + flowType: CreateWalletFlow; + }) { + if (flowType === CreateWalletFlow.Restore && password && mnemonic) { + return { + password, + mnemonic, + flowType: CreateWalletFlow.Restore as const, + }; + } + + if (flowType === CreateWalletFlow.Import && mnemonic) { + return { mnemonic, flowType: CreateWalletFlow.Import as const }; + } + + if (flowType === CreateWalletFlow.Create && password) { + return { password, flowType: CreateWalletFlow.Create as const }; + } + + throw new Error('Invalid create wallet parameters.'); + } + + /** + * Creates a new multichain account wallet by importing an existing mnemonic. + * + * @param mnemonic - The mnemonic to use to create the new wallet. + * @returns The new multichain account wallet. + */ + async #createWalletByImport( + mnemonic: Uint8Array, + ): Promise>> { + log(`Creating new wallet by importing an existing mnemonic...`); const existingKeyrings = this.#messenger.call( 'KeyringController:getKeyringsByType', KeyringTypes.hd, ) as HdKeyring[]; - const mnemonicAsBytes = mnemonicPhraseToBytes(mnemonic); - const alreadyHasImportedSrp = existingKeyrings.some((keyring) => { if (!keyring.mnemonic) { return false; } - return areUint8ArraysEqual(keyring.mnemonic, mnemonicAsBytes); + return areUint8ArraysEqual(keyring.mnemonic, mnemonic); }); if (alreadyHasImportedSrp) { throw new Error('This Secret Recovery Phrase has already been imported.'); } - log(`Creating new wallet...`); - const result = await this.#messenger.call( 'KeyringController:addNewKeyring', KeyringTypes.hd, { mnemonic }, ); - const wallet = new MultichainAccountWallet({ + // The wallet is ripe for discovery + return new MultichainAccountWallet({ providers: this.#providers, entropySource: result.id, messenger: this.#messenger, }); + } + + /** + * Creates a new multichain account wallet by creating a new vault and keychain. + * + * @param password - The password to encrypt the vault with. + * @returns The new multichain account wallet. + */ + async #createWalletByNewVault( + password: string, + ): Promise>> { + log(`Creating new wallet by creating a new vault and keychain...`); + await this.#messenger.call( + 'KeyringController:createNewVaultAndKeychain', + password, + ); + + const entropySourceId = (await this.#messenger.call( + 'KeyringController:withKeyring', + { type: KeyringTypes.hd }, + async ({ metadata }) => { + return metadata.id; + }, + )) as string; + + // The wallet is ripe for discovery + return new MultichainAccountWallet({ + providers: this.#providers, + entropySource: entropySourceId, + messenger: this.#messenger, + }); + } + + /** + * Creates a new multichain account wallet by restoring a vault and keyring. + * + * @param password - The password to encrypt the vault with. + * @param mnemonic - The mnemonic to use to restore the new wallet. + * @returns The new multichain account wallet. + */ + async #createWalletByRestore( + password: string, + mnemonic: Uint8Array, + ): Promise>> { + log(`Creating new wallet by restoring vault and keyring...`); + await this.#messenger.call( + 'KeyringController:createNewVaultAndRestore', + password, + mnemonic, + ); + + const entropySourceId = (await this.#messenger.call( + 'KeyringController:withKeyring', + { type: KeyringTypes.hd }, + async ({ metadata }) => { + return metadata.id; + }, + )) as string; + + // The wallet is ripe for discovery + return new MultichainAccountWallet({ + providers: this.#providers, + entropySource: entropySourceId, + messenger: this.#messenger, + }); + } + + /** + * Creates a new multichain account wallet by either importing an existing mnemonic, + * creating a new vault and keychain, or restoring a vault and keyring. + * + * NOTE: This method should only be called in client code where a mutex lock is acquired. + * `discoverAccounts` should be called after this method to discover and create accounts. + * + * @param options - Options. + * @param options.mnemonic - The mnemonic to use to create the new wallet. + * @param options.password - The password to encrypt the vault with. + * @param options.flowType - The flow type to use to create the new wallet. + * @throws If the mnemonic has already been imported. + * @returns The new multichain account wallet. + */ + async createMultichainAccountWallet({ + mnemonic, + password, + flowType, + }: { + mnemonic?: Uint8Array; + password?: string; + flowType: CreateWalletFlow; + }): Promise>> { + const params: CreateWalletValidatedParams = + this.#getValidatedCreateWalletParams({ + mnemonic, + password, + flowType, + }); + + let wallet: + | MultichainAccountWallet> + | undefined; + + if (params.flowType === CreateWalletFlow.Import) { + wallet = await this.#createWalletByImport(params.mnemonic); + } else if (params.flowType === CreateWalletFlow.Create) { + wallet = await this.#createWalletByNewVault(params.password); + } else if (params.flowType === CreateWalletFlow.Restore) { + wallet = await this.#createWalletByRestore( + params.password, + params.mnemonic, + ); + } + + assert(wallet, 'Failed to create wallet.'); this.#wallets.set(wallet.id, wallet); diff --git a/packages/multichain-account-service/src/MultichainAccountWallet.test.ts b/packages/multichain-account-service/src/MultichainAccountWallet.test.ts index 09d1b0e9420..f554b10c814 100644 --- a/packages/multichain-account-service/src/MultichainAccountWallet.test.ts +++ b/packages/multichain-account-service/src/MultichainAccountWallet.test.ts @@ -12,10 +12,15 @@ import { EthAccountType, SolAccountType, type EntropySourceId, + KeyringAccountEntropyTypeOption, } from '@metamask/keyring-api'; import type { InternalAccount } from '@metamask/keyring-internal-api'; -import { MultichainAccountWallet } from './MultichainAccountWallet'; +import { + type WalletState, + MultichainAccountWallet, +} from './MultichainAccountWallet'; +import type { BaseBip44AccountProvider } from './providers'; import type { MockAccountProvider } from './tests'; import { MOCK_HD_ACCOUNT_1, @@ -66,23 +71,47 @@ function setup({ providers: MockAccountProvider[]; messenger: MultichainAccountServiceMessenger; } { - providers ??= accounts.map((providerAccounts, i) => { - return setupNamedAccountProvider({ - name: `Mocked Provider ${i}`, - accounts: providerAccounts, - index: i, - }); - }); + const providersList = (providers ?? + accounts.map((providerAccounts, i) => { + return setupNamedAccountProvider({ + name: `Mocked Provider ${i}`, + accounts: providerAccounts, + index: i, + }); + })) as MockAccountProvider[]; const serviceMessenger = getMultichainAccountServiceMessenger(messenger); const wallet = new MultichainAccountWallet>({ entropySource, - providers, + providers: providersList as unknown as BaseBip44AccountProvider[], messenger: serviceMessenger, }); - return { wallet, providers, messenger: serviceMessenger }; + const walletState = accounts.reduce( + (state, providerAccounts, idx) => { + const providerName = providersList[idx].getName(); + for (const account of providerAccounts) { + if ( + 'options' in account && + account.options?.entropy?.type === + KeyringAccountEntropyTypeOption.Mnemonic + ) { + const groupIndexKey = account.options.entropy.groupIndex; + state[groupIndexKey] ??= {}; + const groupState = state[groupIndexKey]; + groupState[providerName] ??= []; + groupState[providerName].push(account.id); + } + } + return state; + }, + {}, + ); + + wallet.init(walletState); + + return { wallet, providers: providersList, messenger: serviceMessenger }; } describe('MultichainAccountWallet', () => { @@ -152,130 +181,42 @@ describe('MultichainAccountWallet', () => { }); }); - describe('sync', () => { - it('force sync wallet after account provider got new account', () => { - const mockEvmAccount = MOCK_WALLET_1_EVM_ACCOUNT; - const provider = setupNamedAccountProvider({ - accounts: [mockEvmAccount], - }); - const { wallet } = setup({ - providers: [provider], - }); - - expect(wallet.getMultichainAccountGroups()).toHaveLength(1); - expect(wallet.getAccountGroups()).toHaveLength(1); // We can still get "basic" groups too. - - // Add a new account for the next index. - provider.getAccounts.mockReturnValue([ - mockEvmAccount, - { - ...mockEvmAccount, - options: { - ...mockEvmAccount.options, - entropy: { - ...mockEvmAccount.options.entropy, - groupIndex: 1, - }, - }, - }, - ]); - - // Force sync, so the wallet will "find" a new multichain account. - wallet.sync(); - expect(wallet.getAccountGroups()).toHaveLength(2); - expect(wallet.getMultichainAccountGroups()).toHaveLength(2); - }); - - it('skips non-matching wallet during sync', () => { - const mockEvmAccount = MOCK_WALLET_1_EVM_ACCOUNT; - const provider = setupNamedAccountProvider({ - accounts: [mockEvmAccount], - }); - const { wallet } = setup({ - providers: [provider], - }); - - expect(wallet.getMultichainAccountGroups()).toHaveLength(1); - - // Add a new account for another index but not for this wallet. - provider.getAccounts.mockReturnValue([ - mockEvmAccount, - { - ...mockEvmAccount, - options: { - ...mockEvmAccount.options, - entropy: { - ...mockEvmAccount.options.entropy, - id: 'mock-unknown-entropy-id', - groupIndex: 1, - }, - }, - }, - ]); - - // Even if we have a new account, it's not for this wallet, so it should - // not create a new multichain account! - wallet.sync(); - expect(wallet.getMultichainAccountGroups()).toHaveLength(1); - }); - - it('cleans up old multichain account group during sync', () => { - const mockEvmAccount = MOCK_WALLET_1_EVM_ACCOUNT; - const provider = setupNamedAccountProvider({ - accounts: [mockEvmAccount], - }); - const { wallet } = setup({ - providers: [provider], - }); - - expect(wallet.getMultichainAccountGroups()).toHaveLength(1); - - // Account for index 0 got removed, thus, the multichain account for index 0 - // will also be removed. - provider.getAccounts.mockReturnValue([]); - - // We should not have any multichain account anymore. - wallet.sync(); - expect(wallet.getMultichainAccountGroups()).toHaveLength(0); - }); - }); - describe('createMultichainAccountGroup', () => { - it('creates a multichain account group for a given index', async () => { - const groupIndex = 1; - - const mockEvmAccount = MockAccountBuilder.from(MOCK_HD_ACCOUNT_1) - .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) - .withGroupIndex(0) - .get(); + it('creates a multichain account group for a given index (waitForAllProvidersToFinishCreatingAccounts = false)', async () => { + const groupIndex = 0; const { wallet, providers } = setup({ - accounts: [[mockEvmAccount]], // 1 provider + accounts: [[], []], // 1 provider }); - const [provider] = providers; - const mockNextEvmAccount = MockAccountBuilder.from(mockEvmAccount) + const [evmProvider, solProvider] = providers; + const mockNextEvmAccount = MockAccountBuilder.from(MOCK_HD_ACCOUNT_1) .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) .withGroupIndex(groupIndex) .get(); // 1. Create the accounts for the new index and returns their IDs. - provider.createAccounts.mockResolvedValueOnce([mockNextEvmAccount]); + evmProvider.createAccounts.mockResolvedValueOnce([mockNextEvmAccount]); // 2. When the wallet creates a new multichain account group, it will query // all accounts for this given index (so similar to the one we just created). - provider.getAccounts.mockReturnValueOnce([mockNextEvmAccount]); + evmProvider.getAccounts.mockReturnValueOnce([mockNextEvmAccount]); // 3. Required when we call `getAccounts` (below) on the multichain account. - provider.getAccount.mockReturnValueOnce(mockNextEvmAccount); + evmProvider.getAccount.mockReturnValueOnce(mockNextEvmAccount); + + solProvider.createAccounts.mockResolvedValueOnce([MOCK_SOL_ACCOUNT_1]); + solProvider.getAccounts.mockReturnValueOnce([MOCK_SOL_ACCOUNT_1]); + solProvider.getAccount.mockReturnValueOnce(MOCK_SOL_ACCOUNT_1); const specificGroup = await wallet.createMultichainAccountGroup(groupIndex); expect(specificGroup.groupIndex).toBe(groupIndex); const internalAccounts = specificGroup.getAccounts(); - expect(internalAccounts).toHaveLength(1); + expect(internalAccounts).toHaveLength(2); expect(internalAccounts[0].type).toBe(EthAccountType.Eoa); + expect(internalAccounts[1].type).toBe(SolAccountType.DataAccount); }); - it('returns the same reference when re-creating using the same index', async () => { + it('returns the same reference when re-creating using the same index (waitForAllProvidersToFinishCreatingAccounts = false)', async () => { const { wallet } = setup({ accounts: [[MOCK_HD_ACCOUNT_1]], }); @@ -286,7 +227,7 @@ describe('MultichainAccountWallet', () => { expect(newGroup).toBe(group); }); - it('fails to create an account beyond the next index', async () => { + it('fails to create an account beyond the next index (waitForAllProvidersToFinishCreatingAccounts = false)', async () => { const { wallet } = setup({ accounts: [[MOCK_HD_ACCOUNT_1]], }); @@ -299,78 +240,103 @@ describe('MultichainAccountWallet', () => { ); }); - it('fails to create an account group if the EVM provider fails to create its account', async () => { + it('creates an account group if only some of the providers fail to create its account (waitForAllProvidersToFinishCreatingAccounts = true)', async () => { const groupIndex = 1; + // Baseline accounts at index 0 for two providers const mockEvmAccount = MockAccountBuilder.from(MOCK_HD_ACCOUNT_1) .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) .withGroupIndex(0) .get(); + const mockSolAccount = MockAccountBuilder.from(MOCK_SOL_ACCOUNT_1) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(0) + .get(); const { wallet, providers } = setup({ - accounts: [[mockEvmAccount]], // 1 provider + accounts: [[mockEvmAccount], [mockSolAccount]], // 2 providers }); - const [provider] = providers; - provider.createAccounts.mockRejectedValueOnce( - new Error('Unable to create accounts'), - ); + const [failingProvider, succeedingProvider] = providers; - await expect( - wallet.createMultichainAccountGroup(groupIndex), - ).rejects.toThrow( - 'Unable to create multichain account group for index: 1 with provider "Mocked Provider 0"', + // Arrange: first provider fails, second succeeds creating one account at index 1 + failingProvider.createAccounts.mockRejectedValueOnce( + new Error('Unable to create accounts'), ); - }); - it('does not fail to create an account group if a non-EVM provider fails to create its account', async () => { - const groupIndex = 0; - const mockEvmAccount = MockAccountBuilder.from(MOCK_HD_ACCOUNT_1) + const mockNextSolAccount = MockAccountBuilder.from(mockSolAccount) .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) .withGroupIndex(groupIndex) .get(); - const { wallet, providers } = setup({ - accounts: [[], []], - }); - - const [evmProvider, solProvider] = providers; + succeedingProvider.createAccounts.mockResolvedValueOnce([ + mockNextSolAccount, + ]); - const mockSolProviderError = jest - .fn() - .mockRejectedValue('Unable to create'); - evmProvider.createAccounts.mockResolvedValueOnce([mockEvmAccount]); - solProvider.createAccounts.mockImplementation(mockSolProviderError); + succeedingProvider.getAccounts.mockReturnValueOnce([mockNextSolAccount]); + succeedingProvider.getAccount.mockReturnValueOnce(mockNextSolAccount); - await wallet.createMultichainAccountGroup(groupIndex); + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + const group = await wallet.createMultichainAccountGroup(groupIndex, { + waitForAllProvidersToFinishCreatingAccounts: true, + }); - expect( - await wallet.createMultichainAccountGroup(groupIndex), - ).toBeDefined(); - expect(mockSolProviderError).toHaveBeenCalled(); + // Should warn about partial failure but still create the group + expect(consoleSpy).toHaveBeenCalledWith( + `Unable to create some accounts for group index: ${groupIndex}. Providers threw the following errors:\n- Mocked Provider 0: Unable to create accounts`, + ); + expect(group.groupIndex).toBe(groupIndex); + const internalAccounts = group.getAccounts(); + expect(internalAccounts).toHaveLength(1); + expect(internalAccounts[0]).toStrictEqual(mockNextSolAccount); }); - it('fails to create an account group if any of the provider fails to create its account and waitForAllProvidersToFinishCreatingAccounts is true', async () => { - const groupIndex = 1; - - const mockEvmAccount = MockAccountBuilder.from(MOCK_HD_ACCOUNT_1) - .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) - .withGroupIndex(0) - .get(); + it('fails to create an account group if all providers fail to create their accounts (waitForAllProvidersToFinishCreatingAccounts = true)', async () => { const { wallet, providers } = setup({ - accounts: [[mockEvmAccount]], // 1 provider + accounts: [[], []], }); - const [provider] = providers; - provider.createAccounts.mockRejectedValueOnce( + + const [failingProvider1, failingProvider2] = providers; + + failingProvider1.createAccounts.mockRejectedValueOnce( + new Error('Unable to create accounts'), + ); + + failingProvider2.createAccounts.mockRejectedValueOnce( new Error('Unable to create accounts'), ); await expect( - wallet.createMultichainAccountGroup(groupIndex, { + wallet.createMultichainAccountGroup(0, { waitForAllProvidersToFinishCreatingAccounts: true, }), ).rejects.toThrow( - 'Unable to create multichain account group for index: 1', + 'Unable to create multichain account group for index: 0 due to provider failures:\n- Mocked Provider 0: Unable to create accounts\n- Mocked Provider 1: Unable to create accounts', + ); + + expect(wallet.getAccountGroups()).toHaveLength(0); + }); + + it('logs an error if a non-EVM provider fails to create its account (waitForAllProvidersToFinishCreatingAccounts = false)', async () => { + const { wallet, providers } = setup({ + accounts: [[], []], + }); + + const [succeedingProvider, failingProvider] = providers; + + succeedingProvider.createAccounts.mockResolvedValueOnce([ + MOCK_HD_ACCOUNT_1, + ]); + + failingProvider.createAccounts.mockRejectedValueOnce( + new Error('Unable to create accounts'), + ); + + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + await wallet.createMultichainAccountGroup(0); + + expect(consoleSpy).toHaveBeenCalledWith( + 'Unable to create some accounts for group index: 0 with provider "Mocked Provider 1". Error: Unable to create accounts', ); }); }); @@ -423,6 +389,7 @@ describe('MultichainAccountWallet', () => { expect(internalAccounts).toHaveLength(2); // EVM + SOL. expect(internalAccounts[0].type).toBe(EthAccountType.Eoa); expect(internalAccounts[1].type).toBe(SolAccountType.DataAccount); + expect(wallet.getAccountGroups()).toHaveLength(2); }); }); diff --git a/packages/multichain-account-service/src/MultichainAccountWallet.ts b/packages/multichain-account-service/src/MultichainAccountWallet.ts index f90c0a73694..ec89c7c2100 100644 --- a/packages/multichain-account-service/src/MultichainAccountWallet.ts +++ b/packages/multichain-account-service/src/MultichainAccountWallet.ts @@ -23,8 +23,12 @@ import { projectLogger as log, WARNING_PREFIX, } from './logger'; -import { MultichainAccountGroup } from './MultichainAccountGroup'; -import { EvmAccountProvider, type NamedAccountProvider } from './providers'; +import { + type GroupState, + MultichainAccountGroup, +} from './MultichainAccountGroup'; +import type { ServiceState, StateKeys } from './MultichainAccountService'; +import { type BaseBip44AccountProvider, EvmAccountProvider } from './providers'; import type { MultichainAccountServiceMessenger } from './types'; /** @@ -33,12 +37,17 @@ import type { MultichainAccountServiceMessenger } from './types'; type AccountProviderDiscoveryContext< Account extends Bip44Account, > = { - provider: NamedAccountProvider; + provider: BaseBip44AccountProvider; stopped: boolean; groupIndex: number; accounts: Account[]; }; +export type WalletState = ServiceState[StateKeys['entropySource']]; + +// type alias to make clear this state is generated by discovery +type DiscoveredGroupsState = WalletState; + /** * A multichain account wallet that holds multiple multichain accounts (one multichain account per * group index). @@ -51,7 +60,7 @@ export class MultichainAccountWallet< readonly #id: MultichainAccountWalletId; - readonly #providers: NamedAccountProvider[]; + readonly #providers: BaseBip44AccountProvider[]; readonly #entropySource: EntropySourceId; @@ -61,7 +70,6 @@ export class MultichainAccountWallet< readonly #log: Logger; - // eslint-disable-next-line @typescript-eslint/prefer-readonly #initialized = false; #status: MultichainAccountWalletStatus; @@ -71,7 +79,7 @@ export class MultichainAccountWallet< entropySource, messenger, }: { - providers: NamedAccountProvider[]; + providers: BaseBip44AccountProvider[]; entropySource: EntropySourceId; messenger: MultichainAccountServiceMessenger; }) { @@ -85,69 +93,37 @@ export class MultichainAccountWallet< // Initial synchronization (don't emit events during initialization). this.#status = 'uninitialized'; - this.sync(); - this.#initialized = true; - this.#status = 'ready'; } /** - * Force wallet synchronization. + * Initialize the wallet and construct the internal representation of multichain account groups. * - * This can be used if account providers got new accounts that the wallet - * doesn't know about. + * @param walletState - The wallet state. */ - sync(): void { - this.#log('Synchronizing with account providers...'); - for (const provider of this.#providers) { - for (const account of provider.getAccounts()) { - const { entropy } = account.options; - - // Filter for this wallet only. - if (entropy.id !== this.entropySource) { - continue; - } - - // This multichain account might exists already. - let multichainAccount = this.#accountGroups.get(entropy.groupIndex); - if (!multichainAccount) { - multichainAccount = new MultichainAccountGroup({ - groupIndex: entropy.groupIndex, - wallet: this, - providers: this.#providers, - messenger: this.#messenger, - }); - - // This existing multichain account group might differ from the - // `createMultichainAccountGroup` behavior. When creating a new - // group, we expect the providers to all succeed. But here, we're - // just fetching the account lists from them, so this group might - // not be "aligned" yet (e.g having a missing Solana account). - // - // Since "aligning" is an async operation, it would have to be run - // after the first-sync. - // TODO: Implement align mechanism to create "missing" accounts. - - this.#log(`Found a new group: [${multichainAccount.id}]`); - this.#accountGroups.set(entropy.groupIndex, multichainAccount); - } - } + init(walletState: WalletState) { + this.#log('Initializing wallet state...'); + for (const [groupIndex, 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 + const indexAsNumber = Number(groupIndex); + const group = new MultichainAccountGroup({ + groupIndex: indexAsNumber, + wallet: this, + providers: this.#providers, + messenger: this.#messenger, + }); + + this.#log(`Creating new group for index ${indexAsNumber}...`); + + group.init(groupState); + + this.#accountGroups.set(indexAsNumber, group); } - - // Now force-sync all remaining multichain accounts. - for (const [ - groupIndex, - multichainAccount, - ] of this.#accountGroups.entries()) { - multichainAccount.sync(); - - // Clean up old multichain accounts. - if (!multichainAccount.hasAccounts()) { - this.#log(`Deleting group: [${multichainAccount.id}]`); - this.#accountGroups.delete(groupIndex); - } + if (!this.#initialized) { + this.#initialized = true; + this.#status = 'ready'; } - this.#log('Synchronized'); + this.#log('Finished initializing wallet state...'); } /** @@ -322,11 +298,8 @@ export class MultichainAccountWallet< } let group = this.getMultichainAccountGroup(groupIndex); - if (group) { - // If the group already exists, we just `sync` it and returns the same - // reference. - group.sync(); + if (group) { this.#log( `Trying to re-create existing group: [${group.id}] (idempotent)`, ); @@ -346,24 +319,48 @@ export class MultichainAccountWallet< ), ); - // If any of the provider failed to create their accounts, then we consider the - // multichain account group to have failed too. - if (results.some((result) => result.status === 'rejected')) { - // NOTE: Some accounts might still have been created on other account providers. We - // don't rollback them. - const error = `Unable to create multichain account group for index: ${groupIndex}`; - - let message = `${error}:`; - for (const result of results) { - if (result.status === 'rejected') { - message += `\n- ${result.reason}`; - } + let failureCount = 0; + + const providerFailures = results.reduce((acc, result, idx) => { + if (result.status === 'rejected') { + failureCount += 1; + acc += `\n- ${this.#providers[idx].getName()}: ${result.reason.message}`; } - this.#log(`${WARNING_PREFIX} ${message}`); + return acc; + }, ''); + + const everyProviderFailed = failureCount === this.#providers.length; + + if (everyProviderFailed) { + // We throw an error if there's a failure on every provider + throw new Error( + `Unable to create multichain account group for index: ${groupIndex} due to provider failures:${providerFailures}`, + ); + } else if (providerFailures) { + // We warn there's failures on some providers and thus misalignment, but we still create the group + const message = `Unable to create some accounts for group index: ${groupIndex}. Providers threw the following errors:${providerFailures}`; console.warn(message); - - throw new Error(error); + this.#log(`${WARNING_PREFIX} ${message}`); } + + group = new MultichainAccountGroup({ + wallet: this, + providers: this.#providers, + groupIndex, + messenger: this.#messenger, + }); + + // No need to fetch the accounts list from the AccountsController since we already have the account IDs to be used in the controller + const groupState = results.reduce((state, result, idx) => { + if (result.status === 'fulfilled') { + state[this.#providers[idx].getName()] = result.value.map( + (account) => account.id, + ); + } + return state; + }, {}); + + group.init(groupState); } else { // Extract the EVM provider from the list of providers. // We will only await the EVM provider to create its accounts, while @@ -374,18 +371,30 @@ export class MultichainAccountWallet< 'EVM account provider must be first', ); - // Create account with the EVM provider first and await it. - // If it fails, we don't start creating accounts with other providers. - try { - await evmProvider.createAccounts({ + // Create the group here because the EVM provider will not fail. + // There isn't a failure scenario here since this function is only used by createNextMultichainAccountGroup (no throw on gap error). + // We have to deterministically create the group here because otherwise we can't set the group in state. + group = new MultichainAccountGroup({ + wallet: this, + providers: this.#providers, + groupIndex, + messenger: this.#messenger, + }); + + evmProvider + .createAccounts({ entropySource: this.#entropySource, groupIndex, + }) + .then((account) => { + group?.init({ [evmProvider.getName()]: [account[0].id] }); + return group; + }) + .catch((error) => { + const errorMessage = `Unable to create some accounts for group index: ${groupIndex} with provider "${evmProvider.getName()}". Error: ${(error as Error).message}`; + console.warn(errorMessage); + this.#log(`${ERROR_PREFIX} ${errorMessage}:`, error); }); - } catch (error) { - const errorMessage = `Unable to create multichain account group for index: ${groupIndex} with provider "${evmProvider.getName()}". Error: ${(error as Error).message}`; - this.#log(`${ERROR_PREFIX} ${errorMessage}:`, error); - throw new Error(errorMessage); - } // Create account with other providers in the background otherProviders.forEach((provider) => { @@ -394,51 +403,23 @@ export class MultichainAccountWallet< entropySource: this.#entropySource, groupIndex, }) + .then((accounts) => { + const accountIds = accounts.map((account) => account.id); + group?.init({ [provider.getName()]: accountIds }); + return group; + }) .catch((error) => { // Log errors from background providers but don't fail the operation - const errorMessage = `Could not to create account with provider "${provider.getName()}" for multichain account group index: ${groupIndex}`; + const errorMessage = `Unable to create some accounts for group index: ${groupIndex} with provider "${provider.getName()}". Error: ${(error as Error).message}`; + console.warn(errorMessage); this.#log(`${WARNING_PREFIX} ${errorMessage}:`, error); }); }); } - // -------------------------------------------------------------------------------- - // READ THIS CAREFULLY: - // - // Since we're not "fully supporting multichain" for now, we still rely on single - // :accountCreated events to sync multichain account groups and wallets. Which means - // that even if of the provider fails, some accounts will still be created on some - // other providers and will become "available" on the `AccountsController`, like: - // - // 1. Creating a multichain account group for index 1 - // 2. EvmAccountProvider.createAccounts returns the EVM account for index 1 - // * AccountsController WILL fire :accountCreated for this account - // * This account WILL BE "available" on the AccountsController state - // 3. SolAccountProvider.createAccounts fails to create a Solana account for index 1 - // * AccountsController WON't fire :accountCreated for this account - // * This account WON'T be "available" on the Account - // 4. MultichainAccountService will receive a :accountCreated for the EVM account from - // step 2 and will create a new multichain account group for index 1, but it won't - // receive any event for the Solana account of this group. Thus, this group won't be - // "aligned" (missing "blockchain account" on this group). - // - // -------------------------------------------------------------------------------- - - // Because of the :accountAdded automatic sync, we might already have created the - // group, so we first try to get it. - group = this.getMultichainAccountGroup(groupIndex); - if (!group) { - // If for some reason it's still not created, we're creating it explicitly now: - group = new MultichainAccountGroup({ - wallet: this, - providers: this.#providers, - groupIndex, - messenger: this.#messenger, - }); - } + // Register the account(s) to our internal map. + this.#accountGroups.set(groupIndex, group); - // Register the account to our internal map. - this.#accountGroups.set(groupIndex, group); // `group` cannot be undefined here. this.#log(`New group created: [${group.id}]`); if (this.#initialized) { @@ -515,6 +496,17 @@ export class MultichainAccountWallet< // Start with the next available group index (so we can resume the discovery // from there). let maxGroupIndex = this.getNextGroupIndex(); + const discoveredGroupsState: DiscoveredGroupsState = {}; + + const addDiscoveryResultToState = ( + result: Account[], + providerName: string, + groupIndex: number, + ) => { + const accountIds = result.map((account) => account.id); + discoveredGroupsState[groupIndex] ??= {}; + discoveredGroupsState[groupIndex][providerName] = accountIds; + }; // One serialized loop per provider; all run concurrently const runProviderDiscovery = async ( @@ -532,10 +524,10 @@ export class MultichainAccountWallet< let accounts: Account[] = []; try { - accounts = await context.provider.discoverAccounts({ + accounts = (await context.provider.discoverAccounts({ entropySource: this.#entropySource, groupIndex: targetGroupIndex, - }); + })) as Account[]; } catch (error) { context.stopped = true; console.error(error); @@ -561,6 +553,8 @@ export class MultichainAccountWallet< context.accounts = context.accounts.concat(accounts); + addDiscoveryResultToState(accounts, providerName, targetGroupIndex); + const nextGroupIndex = targetGroupIndex + 1; context.groupIndex = nextGroupIndex; @@ -581,9 +575,20 @@ export class MultichainAccountWallet< // Start discovery for each providers. await Promise.all(providerContexts.map(runProviderDiscovery)); - // Sync the wallet after discovery to ensure that the newly added accounts are added into their groups. - // We can potentially remove this if we know that this race condition is not an issue in practice. - this.sync(); + // Create discovered groups + for (const [groupIndex, groupState] of Object.entries( + discoveredGroupsState, + )) { + const indexAsNumber = Number(groupIndex); + const group = new MultichainAccountGroup({ + wallet: this, + providers: this.#providers, + groupIndex: indexAsNumber, + messenger: this.#messenger, + }); + group.init(groupState); + this.#accountGroups.set(indexAsNumber, group); + } // Align missing accounts from group. This is required to create missing account from non-discovered // indexes for some providers. diff --git a/packages/multichain-account-service/src/providers/BaseBip44AccountProvider.ts b/packages/multichain-account-service/src/providers/BaseBip44AccountProvider.ts index fd3e853d0c2..aade4098762 100644 --- a/packages/multichain-account-service/src/providers/BaseBip44AccountProvider.ts +++ b/packages/multichain-account-service/src/providers/BaseBip44AccountProvider.ts @@ -46,44 +46,58 @@ export type NamedAccountProvider< export abstract class BaseBip44AccountProvider implements NamedAccountProvider { protected readonly messenger: MultichainAccountServiceMessenger; + accounts: Bip44Account['id'][] = []; + constructor(messenger: MultichainAccountServiceMessenger) { this.messenger = messenger; } abstract getName(): string; - #getAccounts( - filter: (account: KeyringAccount) => boolean = () => true, - ): Bip44Account[] { - const accounts: Bip44Account[] = []; - - for (const account of this.messenger.call( - // NOTE: Even though the name is misleading, this only fetches all internal - // accounts, including EVM and non-EVM. We might wanna change this action - // name once we fully support multichain accounts. - 'AccountsController:listMultichainAccounts', - )) { - if ( - isBip44Account(account) && - this.isAccountCompatible(account) && - filter(account) - ) { - accounts.push(account); - } - } + /** + * Add accounts to the provider. + * + * @param accounts - The accounts to add. + */ + addAccounts(accounts: Bip44Account['id'][]): void { + this.accounts.push(...accounts); + } - return accounts; + /** + * Get the accounts list for the provider. + * + * @returns The accounts list. + */ + #getAccountsList(): Bip44Account['id'][] { + return this.accounts; } + /** + * Get the accounts list for the provider from the AccountsController. + * + * @returns The accounts list. + */ getAccounts(): Bip44Account[] { - return this.#getAccounts(); + const accountsList = this.#getAccountsList(); + const internalAccounts = this.messenger.call( + 'AccountsController:getAccounts', + accountsList, + ); + // we cast here because we know that the accounts are BIP-44 compatible + return internalAccounts as Bip44Account[]; } + /** + * Get the account for the provider. + * + * @param id - The account ID. + * @returns The account. + * @throws If the account is not found. + */ getAccount( id: Bip44Account['id'], ): Bip44Account { - // TODO: Maybe just use a proper find for faster lookup? - const [found] = this.#getAccounts((account) => account.id === id); + const found = this.getAccounts().find((account) => account.id === id); if (!found) { throw new Error(`Unable to find account: ${id}`); diff --git a/packages/multichain-account-service/src/providers/BtcAccountProvider.test.ts b/packages/multichain-account-service/src/providers/BtcAccountProvider.test.ts index a064532d4c2..74a1c351307 100644 --- a/packages/multichain-account-service/src/providers/BtcAccountProvider.test.ts +++ b/packages/multichain-account-service/src/providers/BtcAccountProvider.test.ts @@ -133,7 +133,7 @@ function setup({ const keyring = new MockBtcKeyring(accounts); messenger.registerActionHandler( - 'AccountsController:listMultichainAccounts', + 'AccountsController:getAccounts', () => accounts, ); @@ -213,6 +213,22 @@ describe('BtcAccountProvider', () => { ); }); + it('returns true if an account is compatible', () => { + const account = MOCK_BTC_P2WPKH_ACCOUNT_1; + const { provider } = setup({ + accounts: [account], + }); + expect(provider.isAccountCompatible(account)).toBe(true); + }); + + it('returns false if an account is not compatible', () => { + const account = MOCK_HD_ACCOUNT_1; + const { provider } = setup({ + accounts: [account], + }); + expect(provider.isAccountCompatible(account)).toBe(false); + }); + it('creates accounts', async () => { const accounts = [MOCK_BTC_P2WPKH_ACCOUNT_1]; const { provider, keyring } = setup({ diff --git a/packages/multichain-account-service/src/providers/EvmAccountProvider.test.ts b/packages/multichain-account-service/src/providers/EvmAccountProvider.test.ts index b05986d4c9b..b36c1c1e920 100644 --- a/packages/multichain-account-service/src/providers/EvmAccountProvider.test.ts +++ b/packages/multichain-account-service/src/providers/EvmAccountProvider.test.ts @@ -1,5 +1,6 @@ /* eslint-disable jsdoc/require-jsdoc */ import { publicToAddress } from '@ethereumjs/util'; +import { getUUIDFromAddressOfNormalAccount } from '@metamask/accounts-controller'; import type { Messenger } from '@metamask/base-controller'; import { type KeyringMetadata } from '@metamask/keyring-controller'; import type { @@ -21,7 +22,9 @@ import { MOCK_HD_ACCOUNT_1, MOCK_HD_ACCOUNT_2, MOCK_HD_KEYRING_1, + MOCK_SOL_ACCOUNT_1, MockAccountBuilder, + mockAsInternalAccount, } from '../tests'; import type { AllowedActions, @@ -30,9 +33,13 @@ import type { MultichainAccountServiceEvents, } from '../types'; -jest.mock('@ethereumjs/util', () => ({ - publicToAddress: jest.fn(), -})); +jest.mock('@ethereumjs/util', () => { + const actual = jest.requireActual('@ethereumjs/util'); + return { + ...actual, + publicToAddress: jest.fn(), + }; +}); function mockNextDiscoveryAddress(address: string) { jest.mocked(publicToAddress).mockReturnValue(createBytes(address as Hex)); @@ -143,22 +150,26 @@ function setup({ >; keyring: MockEthKeyring; mocks: { - getAccountByAddress: jest.Mock; mockProviderRequest: jest.Mock; + getAccount: jest.Mock; }; } { const keyring = new MockEthKeyring(accounts); messenger.registerActionHandler( - 'AccountsController:listMultichainAccounts', + 'AccountsController:getAccounts', () => accounts, ); - const mockGetAccountByAddress = jest - .fn() - .mockImplementation((address: string) => - keyring.accounts.find((account) => account.address === address), + const getAccount = jest.fn().mockImplementation((id) => { + return keyring.accounts.find( + (account) => + account.id === id || + getUUIDFromAddressOfNormalAccount(account.address) === id, ); + }); + + messenger.registerActionHandler('AccountsController:getAccount', getAccount); const mockProviderRequest = jest.fn().mockImplementation(({ method }) => { if (method === 'eth_getTransactionCount') { @@ -167,11 +178,6 @@ function setup({ throw new Error(`Unknown method: ${method}`); }); - messenger.registerActionHandler( - 'AccountsController:getAccountByAddress', - mockGetAccountByAddress, - ); - messenger.registerActionHandler( 'KeyringController:withKeyring', async (_, operation) => operation({ keyring, metadata: keyring.metadata }), @@ -206,8 +212,8 @@ function setup({ messenger, keyring, mocks: { - getAccountByAddress: mockGetAccountByAddress, mockProviderRequest, + getAccount, }, }; } @@ -228,12 +234,15 @@ describe('EvmAccountProvider', () => { }); it('gets a specific account', () => { - const account = MOCK_HD_ACCOUNT_1; + const customId = 'custom-id-123'; + const account = MockAccountBuilder.from(MOCK_HD_ACCOUNT_1) + .withId(customId) + .get(); const { provider } = setup({ accounts: [account], }); - expect(provider.getAccount(account.id)).toStrictEqual(account); + expect(provider.getAccount(customId)).toStrictEqual(account); }); it('throws if account does not exist', () => { @@ -248,6 +257,22 @@ describe('EvmAccountProvider', () => { ); }); + it('returns true if an account is compatible', () => { + const account = MOCK_HD_ACCOUNT_1; + const { provider } = setup({ + accounts: [account], + }); + expect(provider.isAccountCompatible(account)).toBe(true); + }); + + it('returns false if an account is not compatible', () => { + const account = MOCK_SOL_ACCOUNT_1; + const { provider } = setup({ + accounts: [account], + }); + expect(provider.isAccountCompatible(account)).toBe(false); + }); + it('does not re-create accounts (idempotent)', async () => { const accounts = [MOCK_HD_ACCOUNT_1, MOCK_HD_ACCOUNT_2]; const { provider } = setup({ @@ -268,8 +293,8 @@ describe('EvmAccountProvider', () => { accounts, }); - mocks.getAccountByAddress.mockReturnValue({ - ...MOCK_HD_ACCOUNT_1, + mocks.getAccount.mockReturnValue({ + ...mockAsInternalAccount(MOCK_HD_ACCOUNT_1), options: {}, // No options, so it cannot be BIP-44 compatible. }); @@ -300,7 +325,7 @@ describe('EvmAccountProvider', () => { }); // Simulate an account not found. - mocks.getAccountByAddress.mockImplementation(() => undefined); + mocks.getAccount.mockImplementation(() => undefined); await expect( provider.createAccounts({ diff --git a/packages/multichain-account-service/src/providers/EvmAccountProvider.ts b/packages/multichain-account-service/src/providers/EvmAccountProvider.ts index 50c5e256833..d5dcb7cc013 100644 --- a/packages/multichain-account-service/src/providers/EvmAccountProvider.ts +++ b/packages/multichain-account-service/src/providers/EvmAccountProvider.ts @@ -1,5 +1,6 @@ import { publicToAddress } from '@ethereumjs/util'; import type { Bip44Account } from '@metamask/account-api'; +import { getUUIDFromAddressOfNormalAccount } from '@metamask/accounts-controller'; import type { HdKeyring } from '@metamask/eth-hd-keyring'; import type { EntropySourceId, KeyringAccount } from '@metamask/keyring-api'; import { EthAccountType } from '@metamask/keyring-api'; @@ -92,6 +93,28 @@ export class EvmAccountProvider extends BaseBip44AccountProvider { return provider; } + /** + * Get the account ID for an EVM account. + * + * Note: Since the account ID is deterministic at the AccountsController level, + * we can use this method to get the account ID from the address. + * + * @param address - The address of the account. + * @returns The account ID. + */ + #getAccountId(address: Hex): string { + return getUUIDFromAddressOfNormalAccount(address); + } + + /** + * Create an EVM account. + * + * @param opts - The options for the creation of the account. + * @param opts.entropySource - The entropy source to use for the creation of the account. + * @param opts.groupIndex - The index of the group to create the account for. + * @param opts.throwOnGap - Whether to throw an error if the account index is not contiguous. + * @returns The account ID and a boolean indicating if the account was created. + */ async #createAccount({ entropySource, groupIndex, @@ -122,6 +145,14 @@ export class EvmAccountProvider extends BaseBip44AccountProvider { return result; } + /** + * Create accounts for the EVM provider. + * + * @param opts - The options for the creation of the accounts. + * @param opts.entropySource - The entropy source to use for the creation of the accounts. + * @param opts.groupIndex - The index of the group to create the accounts for. + * @returns The accounts for the EVM provider. + */ async createAccounts({ entropySource, groupIndex, @@ -135,9 +166,11 @@ export class EvmAccountProvider extends BaseBip44AccountProvider { throwOnGap: true, }); + const accountId = this.#getAccountId(address); + const account = this.messenger.call( - 'AccountsController:getAccountByAddress', - address, + 'AccountsController:getAccount', + accountId, ); // We MUST have the associated internal account. @@ -149,6 +182,14 @@ export class EvmAccountProvider extends BaseBip44AccountProvider { return accountsArray; } + /** + * Get the transaction count for an EVM account. + * This method uses a retry and timeout mechanism to handle transient failures. + * + * @param provider - The provider to use for the transaction count. + * @param address - The address of the account. + * @returns The transaction count. + */ async #getTransactionCount( provider: Provider, address: Hex, @@ -239,10 +280,13 @@ export class EvmAccountProvider extends BaseBip44AccountProvider { 'Created account does not match address from group index.', ); + const accountId = this.#getAccountId(address); + const account = this.messenger.call( - 'AccountsController:getAccountByAddress', - address, + 'AccountsController:getAccount', + accountId, ); + assertInternalAccountExists(account); assertIsBip44Account(account); return [account]; diff --git a/packages/multichain-account-service/src/providers/SolAccountProvider.test.ts b/packages/multichain-account-service/src/providers/SolAccountProvider.test.ts index ccb380a4b74..13d56bd8b87 100644 --- a/packages/multichain-account-service/src/providers/SolAccountProvider.test.ts +++ b/packages/multichain-account-service/src/providers/SolAccountProvider.test.ts @@ -116,7 +116,7 @@ function setup({ const keyring = new MockSolanaKeyring(accounts); messenger.registerActionHandler( - 'AccountsController:listMultichainAccounts', + 'AccountsController:getAccounts', () => accounts, ); @@ -196,6 +196,22 @@ describe('SolAccountProvider', () => { ); }); + it('returns true if an account is compatible', () => { + const account = MOCK_SOL_ACCOUNT_1; + const { provider } = setup({ + accounts: [account], + }); + expect(provider.isAccountCompatible(account)).toBe(true); + }); + + it('returns false if an account is not compatible', () => { + const account = MOCK_HD_ACCOUNT_1; + const { provider } = setup({ + accounts: [account], + }); + expect(provider.isAccountCompatible(account)).toBe(false); + }); + it('creates accounts', async () => { const accounts = [MOCK_SOL_ACCOUNT_1]; const { provider, keyring } = setup({ diff --git a/packages/multichain-account-service/src/providers/TrxAccountProvider.test.ts b/packages/multichain-account-service/src/providers/TrxAccountProvider.test.ts index d52aaa25f95..5497b6bd6e6 100644 --- a/packages/multichain-account-service/src/providers/TrxAccountProvider.test.ts +++ b/packages/multichain-account-service/src/providers/TrxAccountProvider.test.ts @@ -116,7 +116,7 @@ function setup({ const keyring = new MockTronKeyring(accounts); messenger.registerActionHandler( - 'AccountsController:listMultichainAccounts', + 'AccountsController:getAccounts', () => accounts, ); @@ -196,6 +196,22 @@ describe('TrxAccountProvider', () => { ); }); + it('returns true if an account is compatible', () => { + const account = MOCK_TRX_ACCOUNT_1; + const { provider } = setup({ + accounts: [account], + }); + expect(provider.isAccountCompatible(account)).toBe(true); + }); + + it('returns false if an account is not compatible', () => { + const account = MOCK_HD_ACCOUNT_1; + const { provider } = setup({ + accounts: [account], + }); + expect(provider.isAccountCompatible(account)).toBe(false); + }); + it('creates accounts', async () => { const accounts = [MOCK_TRX_ACCOUNT_1]; const { provider, keyring } = setup({ diff --git a/packages/multichain-account-service/src/tests/messenger.ts b/packages/multichain-account-service/src/tests/messenger.ts index 0eba196ed77..de5452ceb53 100644 --- a/packages/multichain-account-service/src/tests/messenger.ts +++ b/packages/multichain-account-service/src/tests/messenger.ts @@ -47,6 +47,9 @@ export function getMultichainAccountServiceMessenger( 'KeyringController:addNewKeyring', 'NetworkController:findNetworkClientIdByChainId', 'NetworkController:getNetworkClientById', + 'KeyringController:createNewVaultAndKeychain', + 'KeyringController:createNewVaultAndRestore', + 'AccountsController:getAccounts', ], }); } diff --git a/packages/multichain-account-service/src/tests/providers.ts b/packages/multichain-account-service/src/tests/providers.ts index 7ba467ca5bc..c82e9abccc8 100644 --- a/packages/multichain-account-service/src/tests/providers.ts +++ b/packages/multichain-account-service/src/tests/providers.ts @@ -1,18 +1,19 @@ /* eslint-disable jsdoc/require-jsdoc */ import type { Bip44Account } from '@metamask/account-api'; -import { isBip44Account } from '@metamask/account-api'; import type { KeyringAccount } from '@metamask/keyring-api'; import { EvmAccountProvider } from '../providers'; export type MockAccountProvider = { accounts: KeyringAccount[]; + accountsList: KeyringAccount['id'][]; constructor: jest.Mock; getAccount: jest.Mock; getAccounts: jest.Mock; createAccounts: jest.Mock; discoverAccounts: jest.Mock; + addAccounts: jest.Mock; isAccountCompatible?: jest.Mock; getName: jest.Mock; }; @@ -22,11 +23,13 @@ export function makeMockAccountProvider( ): MockAccountProvider { return { accounts, + accountsList: [], constructor: jest.fn(), getAccount: jest.fn(), getAccounts: jest.fn(), createAccounts: jest.fn(), discoverAccounts: jest.fn(), + addAccounts: jest.fn(), isAccountCompatible: jest.fn(), getName: jest.fn(), }; @@ -36,7 +39,6 @@ export function setupNamedAccountProvider({ name = 'Mocked Provider', accounts, mocks = makeMockAccountProvider(), - filter = () => true, index, }: { name?: string; @@ -48,11 +50,10 @@ export function setupNamedAccountProvider({ // You can mock this and all other mocks will re-use that list // of accounts. mocks.accounts = accounts; + mocks.accountsList = accounts.map((account) => account.id); const getAccounts = () => - mocks.accounts.filter( - (account) => isBip44Account(account) && filter(account), - ); + mocks.accounts.filter((account) => mocks.accountsList.includes(account.id)); mocks.getName.mockImplementation(() => name); @@ -63,6 +64,9 @@ export function setupNamedAccountProvider({ getAccounts().find((account) => account.id === id), ); mocks.createAccounts.mockResolvedValue([]); + mocks.addAccounts.mockImplementation((ids: string[]) => + mocks.accountsList.push(...ids), + ); if (index === 0) { // Make the first provider to always be an `EvmAccountProvider`, since we diff --git a/packages/multichain-account-service/src/types.ts b/packages/multichain-account-service/src/types.ts index 39372186d6a..9b3a567d2ea 100644 --- a/packages/multichain-account-service/src/types.ts +++ b/packages/multichain-account-service/src/types.ts @@ -9,12 +9,15 @@ import type { AccountsControllerAccountRemovedEvent, AccountsControllerGetAccountAction, AccountsControllerGetAccountByAddressAction, + AccountsControllerGetAccountsAction, AccountsControllerListMultichainAccountsAction, } from '@metamask/accounts-controller'; import type { RestrictedMessenger } from '@metamask/base-controller'; import type { KeyringAccount } from '@metamask/keyring-api'; import type { KeyringControllerAddNewKeyringAction, + KeyringControllerCreateNewVaultAndKeychainAction, + KeyringControllerCreateNewVaultAndRestoreAction, KeyringControllerGetKeyringsByTypeAction, KeyringControllerGetStateAction, KeyringControllerStateChangeEvent, @@ -127,6 +130,7 @@ export type MultichainAccountServiceEvents = */ export type AllowedActions = | AccountsControllerListMultichainAccountsAction + | AccountsControllerGetAccountsAction | AccountsControllerGetAccountAction | AccountsControllerGetAccountByAddressAction | SnapControllerHandleSnapRequestAction @@ -135,7 +139,9 @@ export type AllowedActions = | KeyringControllerGetKeyringsByTypeAction | KeyringControllerAddNewKeyringAction | NetworkControllerGetNetworkClientByIdAction - | NetworkControllerFindNetworkClientIdByChainIdAction; + | NetworkControllerFindNetworkClientIdByChainIdAction + | KeyringControllerCreateNewVaultAndKeychainAction + | KeyringControllerCreateNewVaultAndRestoreAction; /** * All events published by other modules that {@link MultichainAccountService}