diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 49a28c0feeb..b2291f3e7b0 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -3,9 +3,6 @@ "@typescript-eslint/explicit-function-return-type": { "count": 4 }, - "@typescript-eslint/naming-convention": { - "count": 4 - }, "id-length": { "count": 2 } diff --git a/packages/account-tree-controller/CHANGELOG.md b/packages/account-tree-controller/CHANGELOG.md index e0035c2df17..6b24df35153 100644 --- a/packages/account-tree-controller/CHANGELOG.md +++ b/packages/account-tree-controller/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **BREAKING:** Add `AccountTreeController:removeAccountWallet`([#10253](https://github.com/MetaMask/core/pull/10253)) + - It removes all accounts in a non-primary wallet. + - It rejects removal of the primary HD wallet. + - We now need those new actions on the messenger `MultichainAccountService:removeMultichainAccountWallet` and `KeyringController:removeAccount`. + ### Changed - Bump `@metamask/profile-sync-controller` from `^32.0.0` to `^32.1.0` ([#10184](https://github.com/MetaMask/core/pull/10184)) diff --git a/packages/account-tree-controller/src/AccountTreeController-method-action-types.ts b/packages/account-tree-controller/src/AccountTreeController-method-action-types.ts index 9094a4bcaae..eb19cce0d11 100644 --- a/packages/account-tree-controller/src/AccountTreeController-method-action-types.ts +++ b/packages/account-tree-controller/src/AccountTreeController-method-action-types.ts @@ -39,6 +39,25 @@ export type AccountTreeControllerIsInitializedAction = { handler: AccountTreeController['isInitialized']; }; +/** + * Removes an account wallet and all of its underlying accounts. + * + * The account tree is a derived view of AccountsController state, so this + * method intentionally does not mutate tree nodes directly. Account removal + * causes AccountsController to publish `accountsRemoved`, which lets + * `#handleAccountsRemoved` consistently prune tree nodes, reverse mappings, + * metadata, and selection state. + * + * @param walletId - Account wallet ID. + * @throws If the account tree has not been initialized. + * @throws If the wallet does not exist. + * @throws If the wallet belongs to the primary HD keyring. + */ +export type AccountTreeControllerRemoveAccountWalletAction = { + type: `AccountTreeController:removeAccountWallet`; + handler: AccountTreeController['removeAccountWallet']; +}; + /** * Gets the account wallet object from its ID. * @@ -289,6 +308,7 @@ export type AccountTreeControllerMethodActions = | AccountTreeControllerInitAction | AccountTreeControllerReinitAction | AccountTreeControllerIsInitializedAction + | AccountTreeControllerRemoveAccountWalletAction | AccountTreeControllerGetAccountWalletObjectAction | AccountTreeControllerGetAccountWalletObjectsAction | AccountTreeControllerGetAccountsFromSelectedAccountGroupAction diff --git a/packages/account-tree-controller/src/AccountTreeController.test.ts b/packages/account-tree-controller/src/AccountTreeController.test.ts index f57e6da8a35..128bc6526bb 100644 --- a/packages/account-tree-controller/src/AccountTreeController.test.ts +++ b/packages/account-tree-controller/src/AccountTreeController.test.ts @@ -319,18 +319,26 @@ function setup({ consoleWarn: jest.SpyInstance; }; mocks: { + // eslint-disable-next-line @typescript-eslint/naming-convention KeyringController: { keyrings: KeyringObject[]; getState: jest.Mock; + removeAccount: jest.Mock; verifyPassword: jest.Mock; withController: jest.Mock; }; + // eslint-disable-next-line @typescript-eslint/naming-convention + MultichainAccountService: { + removeMultichainAccountWallet: jest.Mock; + }; + // eslint-disable-next-line @typescript-eslint/naming-convention AccountsController: { accounts: InternalAccount[]; listMultichainAccounts: jest.Mock; getSelectedMultichainAccount: jest.Mock; getAccount: jest.Mock; }; + // eslint-disable-next-line @typescript-eslint/naming-convention UserStorageController: { performGetStorage: jest.Mock; performGetStorageAllFeatureEntries: jest.Mock; @@ -338,6 +346,7 @@ function setup({ performBatchSetStorage: jest.Mock; syncInternalAccountsWithUserStorage: jest.Mock; }; + // eslint-disable-next-line @typescript-eslint/naming-convention AuthenticationController: { getSessionProfile: jest.Mock; }; @@ -347,9 +356,13 @@ function setup({ KeyringController: { keyrings, getState: jest.fn(), + removeAccount: jest.fn().mockResolvedValue(undefined), verifyPassword: jest.fn().mockResolvedValue(undefined), withController: jest.fn(), }, + MultichainAccountService: { + removeMultichainAccountWallet: jest.fn().mockResolvedValue(undefined), + }, AccountsController: { accounts, listMultichainAccounts: jest.fn(), @@ -456,6 +469,11 @@ function setup({ mocks.KeyringController.verifyPassword, ); + messenger.registerActionHandler( + 'KeyringController:removeAccount', + mocks.KeyringController.removeAccount, + ); + // Default: call the callback with no existing keyrings so private-key // imports are a no-op unless the test overrides this handler. mocks.KeyringController.withController.mockImplementation( @@ -472,6 +490,11 @@ function setup({ ); } + messenger.registerActionHandler( + 'MultichainAccountService:removeMultichainAccountWallet', + mocks.MultichainAccountService.removeMultichainAccountWallet, + ); + const accountTreeControllerMessenger = getAccountTreeControllerMessenger(messenger); const controller = new AccountTreeController({ @@ -557,6 +580,229 @@ describe('AccountTreeController', () => { }); }); + describe('removeAccountWallet', () => { + it('throws if the account tree is not initialized', async () => { + const { controller } = setup({ + accounts: [MOCK_HD_ACCOUNT_1], + keyrings: [MOCK_HD_KEYRING_1], + state: MOCK_PREPOPULATED_STATE, + }); + + await expect( + controller.removeAccountWallet(MOCK_PREPOPULATED_WALLET_ID), + ).rejects.toThrow('Account tree is not initialized'); + }); + + it('throws if the wallet does not exist', async () => { + const { controller } = setup(); + controller.init(); + + await expect( + controller.removeAccountWallet( + 'entropy:missing-wallet' as AccountWalletId, + ), + ).rejects.toThrow('Account wallet not found in tree'); + }); + + it('throws before removing the primary entropy wallet', async () => { + const { controller, mocks } = setup({ + accounts: [MOCK_HD_ACCOUNT_1, MOCK_HD_ACCOUNT_2], + keyrings: [MOCK_HD_KEYRING_1, MOCK_HD_KEYRING_2], + }); + controller.init(); + + await expect( + controller.removeAccountWallet( + toMultichainAccountWalletId(MOCK_HD_KEYRING_1.metadata.id), + ), + ).rejects.toThrow('Cannot remove the primary account wallet'); + expect( + mocks.MultichainAccountService.removeMultichainAccountWallet, + ).not.toHaveBeenCalled(); + expect(mocks.KeyringController.removeAccount).not.toHaveBeenCalled(); + }); + + it('removes a secondary entropy wallet through MultichainAccountService', async () => { + const { controller, messenger, mocks } = setup({ + accounts: [MOCK_HD_ACCOUNT_1, MOCK_HD_ACCOUNT_2], + keyrings: [MOCK_HD_KEYRING_1, MOCK_HD_KEYRING_2], + }); + controller.init(); + const walletId = toMultichainAccountWalletId( + MOCK_HD_KEYRING_2.metadata.id, + ); + mocks.MultichainAccountService.removeMultichainAccountWallet.mockImplementation( + async () => { + messenger.publish('AccountsController:accountsRemoved', [ + MOCK_HD_ACCOUNT_2.id, + ]); + }, + ); + + await controller.removeAccountWallet(walletId); + + expect( + mocks.MultichainAccountService.removeMultichainAccountWallet, + ).toHaveBeenCalledWith(MOCK_HD_KEYRING_2.metadata.id); + expect(controller.getAccountWalletObject(walletId)).toBeUndefined(); + }); + + it('force-removes every hardware account through KeyringController', async () => { + const secondHardwareAccount: InternalAccount = { + ...MOCK_HARDWARE_ACCOUNT_1, + id: 'mock-hardware-id-2', + address: '0xDEF', + }; + const { controller, messenger, mocks } = setup({ + accounts: [MOCK_HARDWARE_ACCOUNT_1, secondHardwareAccount], + keyrings: [MOCK_HD_KEYRING_1], + }); + controller.init(); + const walletId = toAccountWalletId( + AccountWalletType.Keyring, + KeyringTypes.ledger, + ); + mocks.KeyringController.removeAccount.mockImplementation( + async (address) => { + const account = [MOCK_HARDWARE_ACCOUNT_1, secondHardwareAccount].find( + (candidate) => candidate.address === address, + ); + messenger.publish('AccountsController:accountsRemoved', [ + account?.id as AccountId, + ]); + }, + ); + + await controller.removeAccountWallet(walletId); + + expect(mocks.KeyringController.removeAccount).toHaveBeenCalledTimes(2); + expect(mocks.KeyringController.removeAccount).toHaveBeenNthCalledWith( + 1, + MOCK_HARDWARE_ACCOUNT_1.address, + ); + expect(mocks.KeyringController.removeAccount).toHaveBeenNthCalledWith( + 2, + secondHardwareAccount.address, + ); + expect(controller.getAccountWalletObject(walletId)).toBeUndefined(); + }); + + it('force-removes Snap accounts through KeyringController', async () => { + const { controller, messenger, mocks } = setup({ + accounts: [MOCK_SNAP_ACCOUNT_2], + keyrings: [MOCK_HD_KEYRING_1], + }); + messenger.registerActionHandler( + 'SnapController:getSnap', + () => + MOCK_SNAP_2 as unknown as ReturnType< + SnapControllerGetSnap['handler'] + >, + ); + controller.init(); + const walletId = toAccountWalletId( + AccountWalletType.Snap, + MOCK_SNAP_2.id, + ); + mocks.KeyringController.removeAccount.mockImplementation(async () => { + messenger.publish('AccountsController:accountsRemoved', [ + MOCK_SNAP_ACCOUNT_2.id, + ]); + }); + + await controller.removeAccountWallet(walletId); + + expect(mocks.KeyringController.removeAccount).toHaveBeenCalledWith( + MOCK_SNAP_ACCOUNT_2.address, + ); + expect(controller.getAccountWalletObject(walletId)).toBeUndefined(); + }); + + it('continues removing accounts after an individual removal fails', async () => { + const secondHardwareAccount: InternalAccount = { + ...MOCK_HARDWARE_ACCOUNT_1, + id: 'mock-hardware-id-2', + address: '0xDEF', + }; + const { controller, messenger, mocks } = setup({ + accounts: [MOCK_HARDWARE_ACCOUNT_1, secondHardwareAccount], + keyrings: [MOCK_HD_KEYRING_1], + }); + controller.init(); + const walletId = toAccountWalletId( + AccountWalletType.Keyring, + KeyringTypes.ledger, + ); + mocks.KeyringController.removeAccount + .mockRejectedValueOnce(new Error('Removal failed')) + .mockImplementationOnce(async () => { + messenger.publish('AccountsController:accountsRemoved', [ + secondHardwareAccount.id, + ]); + }); + + const consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + + await controller.removeAccountWallet(walletId); + + expect(mocks.KeyringController.removeAccount).toHaveBeenCalledTimes(2); + expect( + controller.getAccountWalletObject(walletId)?.groups[ + toAccountGroupId(walletId, MOCK_HARDWARE_ACCOUNT_1.address) + ].accounts, + ).toStrictEqual([MOCK_HARDWARE_ACCOUNT_1.id]); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Account wallet removal is incomplete', + { + walletId, + remainingAccountIds: [MOCK_HARDWARE_ACCOUNT_1.id], + }, + ); + }); + + it('keeps the wallet when an account is missing from AccountsController', async () => { + const { controller, mocks } = setup({ + accounts: [MOCK_HARDWARE_ACCOUNT_1], + keyrings: [MOCK_HD_KEYRING_1], + }); + controller.init(); + const walletId = toAccountWalletId( + AccountWalletType.Keyring, + KeyringTypes.ledger, + ); + mocks.AccountsController.accounts = []; + const consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + + await controller.removeAccountWallet(walletId); + + expect(mocks.KeyringController.removeAccount).not.toHaveBeenCalled(); + expect(controller.getAccountWalletObject(walletId)).toBeDefined(); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Account wallet removal is incomplete', + { + walletId, + remainingAccountIds: [MOCK_HARDWARE_ACCOUNT_1.id], + }, + ); + }); + + it('is exposed through the controller messenger', async () => { + const { accountTreeControllerMessenger, controller } = setup(); + controller.init(); + + await expect( + accountTreeControllerMessenger.call( + 'AccountTreeController:removeAccountWallet', + 'entropy:missing-wallet' as AccountWalletId, + ), + ).rejects.toThrow('Account wallet not found in tree'); + }); + }); + describe('init', () => { it('groups accounts by entropy source, then snapId, then wallet type', () => { const { controller, messenger } = setup({ diff --git a/packages/account-tree-controller/src/AccountTreeController.ts b/packages/account-tree-controller/src/AccountTreeController.ts index 2734a3eb37a..3540e2c39ea 100644 --- a/packages/account-tree-controller/src/AccountTreeController.ts +++ b/packages/account-tree-controller/src/AccountTreeController.ts @@ -11,6 +11,7 @@ import type { AccountId } from '@metamask/accounts-controller'; import type { StateMetadata } from '@metamask/base-controller'; import { BaseController } from '@metamask/base-controller'; import type { TraceCallback } from '@metamask/controller-utils'; +import type { EntropySourceId } from '@metamask/keyring-api'; import { isEvmAccountType } from '@metamask/keyring-api'; import type { InternalAccount } from '@metamask/keyring-internal-api'; import { assert, isCaipChainId } from '@metamask/utils'; @@ -64,6 +65,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'setAccountGroupHidden', 'getAccountWalletObject', 'getAccountWalletObjects', + 'removeAccountWallet', 'getAccountGroupObject', 'clearState', 'syncWithUserStorage', @@ -477,6 +479,15 @@ export class AccountTreeController extends BaseController< return this.#rules[0]; } + /** + * Gets the entropy source ID of the primary HD keyring. + * + * @returns The primary entropy source ID, or `undefined` if no HD keyring exists. + */ + #getPrimaryEntropySource(): EntropySourceId | undefined { + return this.#getEntropyRule().getPrimaryEntropySource(); + } + /** * Rule for Snap-base wallets. * @@ -813,6 +824,119 @@ export class AccountTreeController extends BaseController< } } + /** + * Removes an account wallet and all of its underlying accounts. + * + * The account tree is a derived view of AccountsController state, so this + * method intentionally does not mutate tree nodes directly. Account removal + * causes AccountsController to publish `accountsRemoved`, which lets + * `#handleAccountsRemoved` consistently prune tree nodes, reverse mappings, + * metadata, and selection state. + * + * @param walletId - Account wallet ID. + * @throws If the account tree has not been initialized. + * @throws If the wallet does not exist. + * @throws If the wallet belongs to the primary HD keyring. + */ + async removeAccountWallet(walletId: AccountWalletId): Promise { + if (!this.#initialized) { + throw new Error('Account tree is not initialized'); + } + + this.#assertAccountWalletExists(walletId); + const wallet = this.state.accountTree.wallets[walletId]; + + if (wallet.type === AccountWalletType.Entropy) { + // Handle removal of entropy-based account wallets. + if (wallet.metadata.entropy.id === this.#getPrimaryEntropySource()) { + throw new Error('Cannot remove the primary account wallet'); + } + + await this.messenger.call( + 'MultichainAccountService:removeMultichainAccountWallet', + wallet.metadata.entropy.id, + ); + } else { + // Handle removal of non-entropy-based account wallets (Snap accounts, hardware wallets, etc.). + + // Snapshot IDs before the first removal. Each removeAccount call can + // synchronously publish accountsRemoved and mutate wallet.groups. + const accountIds = Object.values(wallet.groups).flatMap((group) => [ + ...group.accounts, + ]); + const failures: { accountId: AccountId; error: unknown }[] = []; + + for (const accountId of accountIds) { + const account = this.messenger.call( + 'AccountsController:getAccount', + accountId, + ); + if (!account) { + failures.push({ + accountId, + error: new Error('Account not found'), + }); + continue; + } + + try { + // For Snaps, SnapKeyring removes its local account before notifying the Snap + // and catches Snap-side failures, so this also provides forced cleanup + // for Snap accounts. + // + // For hardware wallets, removal is local and does not require the device + // to be connected. + await this.messenger.call( + 'KeyringController:removeAccount', + account.address, + ); + } catch (error) { + failures.push({ accountId, error }); + } + } + + if (failures.length > 0) { + log(`[${walletId}] Failed to remove one or more wallet accounts`, { + failures, + }); + } + } + + // Successful account removal normally prunes the wallet through + // #handleAccountsRemoved. A leftover is diagnostic only: deletion is + // best-effort and may already have produced irreversible side effects. + const remainingWallet = this.getAccountWalletObject(walletId); + if (remainingWallet) { + const remainingAccountIds = Object.values(remainingWallet.groups).flatMap( + (group) => group.accounts, + ); + if (remainingAccountIds.length > 0) { + log(`[${walletId}] Account wallet removal is incomplete`, { + remainingAccountIds, + }); + // Stable message so it stays groupable if reported later. Wallet IDs + // are safe to log in the extra argument: entropy wallets use a ULID, + // keyring wallets use a keyring type, and Snap wallets use a Snap ID. + console.error('Account wallet removal is incomplete', { + walletId, + remainingAccountIds, + }); + } else { + // This cannot occur through the normal event flow because + // #handleAccountsRemoved prunes an empty wallet atomically. Keep the + // diagnostic for externally restored or otherwise inconsistent state. + /* istanbul ignore next */ + log( + `[${walletId}] Account wallet remains in the tree without accounts`, + ); + /* istanbul ignore next */ + console.error('Account wallet remains in the tree without accounts', { + walletId, + }); + } + } + } + /** * Gets the account wallet object from its ID. * diff --git a/packages/account-tree-controller/src/rules/entropy.test.ts b/packages/account-tree-controller/src/rules/entropy.test.ts index 000ae9afd11..f7db4076c8a 100644 --- a/packages/account-tree-controller/src/rules/entropy.test.ts +++ b/packages/account-tree-controller/src/rules/entropy.test.ts @@ -314,4 +314,66 @@ describe('EntropyRule', () => { expect(rule.getComputedAccountGroupName(group)).toBe('Main Account'); }); }); + + describe('getPrimaryEntropySource', () => { + it('returns the first HD keyring metadata ID', () => { + const messenger = getRootMessenger(); + const accountTreeControllerMessenger = + getAccountTreeControllerMessenger(messenger); + const rule = new EntropyRule(accountTreeControllerMessenger); + + messenger.registerActionHandler('KeyringController:getState', () => ({ + isUnlocked: true, + keyrings: [ + MOCK_HD_KEYRING_1, + { + type: KeyringTypes.hd, + metadata: { id: 'mock-keyring-id-2', name: 'HD Keyring 2' }, + accounts: ['0x456'], + }, + ], + })); + + expect(rule.getPrimaryEntropySource()).toBe( + MOCK_HD_KEYRING_1.metadata.id, + ); + }); + + it('skips non-HD keyrings before the first HD keyring', () => { + const messenger = getRootMessenger(); + const accountTreeControllerMessenger = + getAccountTreeControllerMessenger(messenger); + const rule = new EntropyRule(accountTreeControllerMessenger); + + messenger.registerActionHandler('KeyringController:getState', () => ({ + isUnlocked: true, + keyrings: [ + { + type: KeyringTypes.simple, + metadata: { id: 'imported', name: 'Imported' }, + accounts: ['0xabc'], + }, + MOCK_HD_KEYRING_1, + ], + })); + + expect(rule.getPrimaryEntropySource()).toBe( + MOCK_HD_KEYRING_1.metadata.id, + ); + }); + + it('returns undefined when there is no HD keyring', () => { + const messenger = getRootMessenger(); + const accountTreeControllerMessenger = + getAccountTreeControllerMessenger(messenger); + const rule = new EntropyRule(accountTreeControllerMessenger); + + messenger.registerActionHandler('KeyringController:getState', () => ({ + isUnlocked: true, + keyrings: [], + })); + + expect(rule.getPrimaryEntropySource()).toBeUndefined(); + }); + }); }); diff --git a/packages/account-tree-controller/src/rules/entropy.ts b/packages/account-tree-controller/src/rules/entropy.ts index 03fb50953fc..4c154976d9b 100644 --- a/packages/account-tree-controller/src/rules/entropy.ts +++ b/packages/account-tree-controller/src/rules/entropy.ts @@ -6,6 +6,8 @@ import { toMultichainAccountWalletId, } from '@metamask/account-api'; import { isEvmAccountType } from '@metamask/keyring-api'; +import type { EntropySourceId } from '@metamask/keyring-api'; +import type { KeyringObject } from '@metamask/keyring-controller'; import { KeyringTypes } from '@metamask/keyring-controller'; import type { InternalAccount } from '@metamask/keyring-internal-api'; @@ -24,11 +26,26 @@ export class EntropyRule readonly groupType = AccountGroupType.MultichainAccount; getEntropySourceIndex(entropySource: string) { + return this.#getHdKeyrings().findIndex( + (keyring) => keyring.metadata.id === entropySource, + ); + } + + /** + * The primary entropy source is the first HD keyring. + * + * @returns The primary HD keyring's entropy source ID, or `undefined` if none exists. + */ + getPrimaryEntropySource(): EntropySourceId | undefined { + return this.#getHdKeyrings()[0]?.metadata.id; + } + + #getHdKeyrings(): KeyringObject[] { const { keyrings } = this.messenger.call('KeyringController:getState'); - return keyrings - .filter((keyring) => keyring.type === (KeyringTypes.hd as string)) - .findIndex((keyring) => keyring.metadata.id === entropySource); + return keyrings.filter( + (keyring) => keyring.type === (KeyringTypes.hd as string), + ); } match( diff --git a/packages/account-tree-controller/src/types.ts b/packages/account-tree-controller/src/types.ts index 72cbaf7ad50..b3cc4e3e76f 100644 --- a/packages/account-tree-controller/src/types.ts +++ b/packages/account-tree-controller/src/types.ts @@ -16,6 +16,7 @@ import type { import type { TraceCallback } from '@metamask/controller-utils'; import type { KeyringControllerGetStateAction, + KeyringControllerRemoveAccountAction, KeyringControllerVerifyPasswordAction, KeyringControllerWithControllerAction, KeyringControllerWithKeyringV2Action, @@ -26,6 +27,7 @@ import type { MultichainAccountServiceCreateMultichainAccountGroupAction, MultichainAccountServiceCreateMultichainAccountGroupsAction, MultichainAccountServiceCreateMultichainAccountWalletAction, + MultichainAccountServiceRemoveMultichainAccountWalletAction, } from '@metamask/multichain-account-service'; import type { MultichainAccountServiceWalletStatusChangeEvent } from '@metamask/multichain-account-service'; import type { @@ -93,6 +95,7 @@ export type AllowedActions = | AccountsControllerListMultichainAccountsAction | AccountsControllerSetSelectedAccountAction | KeyringControllerGetStateAction + | KeyringControllerRemoveAccountAction | KeyringControllerVerifyPasswordAction | SnapControllerGetSnapAction | UserStorageController.UserStorageControllerGetStateAction @@ -104,6 +107,7 @@ export type AllowedActions = | MultichainAccountServiceCreateMultichainAccountGroupAction | MultichainAccountServiceCreateMultichainAccountGroupsAction | MultichainAccountServiceCreateMultichainAccountWalletAction + | MultichainAccountServiceRemoveMultichainAccountWalletAction | KeyringControllerWithControllerAction | KeyringControllerWithKeyringV2Action | KeyringControllerWithKeyringV2UnsafeAction; diff --git a/packages/account-tree-controller/tests/mockMessenger.ts b/packages/account-tree-controller/tests/mockMessenger.ts index f2c8da75371..99c2584a65e 100644 --- a/packages/account-tree-controller/tests/mockMessenger.ts +++ b/packages/account-tree-controller/tests/mockMessenger.ts @@ -64,7 +64,9 @@ export function getAccountTreeControllerMessenger( 'MultichainAccountService:createMultichainAccountGroup', 'MultichainAccountService:createMultichainAccountGroups', 'MultichainAccountService:createMultichainAccountWallet', + 'MultichainAccountService:removeMultichainAccountWallet', 'KeyringController:getState', + 'KeyringController:removeAccount', 'KeyringController:verifyPassword', 'KeyringController:withController', 'KeyringController:withKeyringV2',