From 3216f7a989bbe2229283f4e69c87325346208123 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Mon, 1 Jun 2026 15:04:19 -0400 Subject: [PATCH 01/21] feat: add deleteAccount --- .../src/MultichainAccountService.ts | 39 +++++++++++++------ .../src/providers/BaseBip44AccountProvider.ts | 13 +++++++ .../src/providers/EvmAccountProvider.ts | 25 ++++++++++++ .../src/providers/SnapAccountProvider.ts | 20 ++++++++++ 4 files changed, 86 insertions(+), 11 deletions(-) diff --git a/packages/multichain-account-service/src/MultichainAccountService.ts b/packages/multichain-account-service/src/MultichainAccountService.ts index aeea53a201..7264aef555 100644 --- a/packages/multichain-account-service/src/MultichainAccountService.ts +++ b/packages/multichain-account-service/src/MultichainAccountService.ts @@ -534,26 +534,43 @@ export class MultichainAccountService { } /** - * Removes a multichain account wallet. + * Removes a multichain account wallet, deleting all of its accounts across + * every registered provider (EVM and snap-based). * - * NOTE: This method should only be called in client code as a revert mechanism. - * At the point that this code is called, discovery shouldn't have been triggered. - * This is meant to be used in the scenario where a seed phrase backup is not successful. + * Per-account deletions are best-effort: a single account failure is logged + * via `reportError` but does not abort cleanup of the remaining accounts. + * The wallet is always removed from the service's internal map at the end. * * @param entropySource - The entropy source of the multichain account wallet. - * @param accountAddress - The address of the account to remove. - * @returns The removed multichain account wallet. */ async removeMultichainAccountWallet( entropySource: EntropySourceId, - accountAddress: string, ): Promise { const wallet = this.#getWallet(entropySource); - await this.#messenger.call( - 'KeyringController:removeAccount', - accountAddress, - ); + for (const group of wallet.getMultichainAccountGroups()) { + for (const account of group.getAccounts()) { + const provider = this.#providers.find((candidate) => + candidate.isAccountCompatible(account), + ); + if (!provider) { + continue; + } + try { + await provider.deleteAccount(account.id); + } catch (error) { + reportError( + this.#messenger, + `Unable to delete account during wallet removal`, + error, + { + provider: provider.getName(), + accountId: account.id, + }, + ); + } + } + } this.#wallets.delete(wallet.id); } diff --git a/packages/multichain-account-service/src/providers/BaseBip44AccountProvider.ts b/packages/multichain-account-service/src/providers/BaseBip44AccountProvider.ts index 5ac23e82d7..3411da31d8 100644 --- a/packages/multichain-account-service/src/providers/BaseBip44AccountProvider.ts +++ b/packages/multichain-account-service/src/providers/BaseBip44AccountProvider.ts @@ -79,6 +79,17 @@ export type Bip44AccountProvider< * @returns The created accounts. */ createAccounts(options: CreateAccountOptions): Promise; + /** + * Delete an account managed by this provider. + * + * Mirrors the v2 keyring `deleteAccount(accountId)` contract. Each provider + * implementation is responsible for resolving any extra information it needs + * (e.g. address for snap-based providers) and for performing the underlying + * keyring removal. + * + * @param id - The id of the account to delete. + */ + deleteAccount(id: Account['id']): Promise; /** * Re-synchronize MetaMask accounts and the providers accounts if needed. * @@ -242,6 +253,8 @@ export abstract class BaseBip44AccountProvider< abstract createAccounts(options: CreateAccountOptions): Promise; + abstract deleteAccount(id: Account['id']): Promise; + abstract discoverAccounts({ entropySource, groupIndex, diff --git a/packages/multichain-account-service/src/providers/EvmAccountProvider.ts b/packages/multichain-account-service/src/providers/EvmAccountProvider.ts index ef739cf5a3..b6a588af07 100644 --- a/packages/multichain-account-service/src/providers/EvmAccountProvider.ts +++ b/packages/multichain-account-service/src/providers/EvmAccountProvider.ts @@ -428,4 +428,29 @@ export class EvmAccountProvider extends BaseBip44AccountProvider { // No-op for the EVM account provider, since keyring accounts are already on // the MetaMask side. } + + /** + * Delete an EVM account by id. + * + * Resolves the account's entropy source from the tracked account, then + * forwards to the v2 HD keyring's `deleteAccount(id)`. When this is the + * last account on a non-primary HD keyring, the keyring controller will + * automatically prune the empty keyring (see + * `KeyringController.#cleanUpEmptiedKeyringsAfter`). + * + * @param id - The id of the account to delete. + */ + async deleteAccount(id: Bip44Account['id']): Promise { + const account = this.getAccount(id); + const entropySource = account.options.entropy.id; + + await this.withKeyringV2( + { id: entropySource }, + async ({ keyring }) => { + await keyring.deleteAccount(id); + }, + ); + + this.accounts.delete(id); + } } diff --git a/packages/multichain-account-service/src/providers/SnapAccountProvider.ts b/packages/multichain-account-service/src/providers/SnapAccountProvider.ts index 0d3ae60c4d..018080b947 100644 --- a/packages/multichain-account-service/src/providers/SnapAccountProvider.ts +++ b/packages/multichain-account-service/src/providers/SnapAccountProvider.ts @@ -447,6 +447,26 @@ export abstract class SnapAccountProvider extends BaseBip44AccountProvider { ); } + /** + * Delete a snap account by id. + * + * Resolves the account's address from the tracked account, then forwards to + * the legacy `SnapKeyring.removeAccount(address)`. The Snap keyring takes + * care of notifying the snap to clean up its own state through the normal + * account-removal flow (same path used by `resyncAccounts`). + * + * @param id - The id of the account to delete. + */ + async deleteAccount(id: Bip44Account['id']): Promise { + const account = this.getAccount(id); + + await this.#withSnapKeyring(async ({ keyring }) => { + await keyring.removeAccount(account.address); + }); + + this.accounts.delete(id); + } + abstract discoverAccounts(options: { entropySource: EntropySourceId; groupIndex: number; From 0af192bde2384ca6e3f7470ef08b730b2b852603 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Mon, 1 Jun 2026 15:05:33 -0400 Subject: [PATCH 02/21] test: update tests --- .../src/MultichainAccountService.test.ts | 102 ++++++++++++++++-- .../src/providers/AccountProviderWrapper.ts | 13 +++ .../src/providers/EvmAccountProvider.test.ts | 38 +++++++ .../src/providers/SnapAccountProvider.test.ts | 29 +++++ .../src/tests/providers.ts | 2 + 5 files changed, 175 insertions(+), 9 deletions(-) diff --git a/packages/multichain-account-service/src/MultichainAccountService.test.ts b/packages/multichain-account-service/src/MultichainAccountService.test.ts index c3d11af4fe..2d797424a0 100644 --- a/packages/multichain-account-service/src/MultichainAccountService.test.ts +++ b/packages/multichain-account-service/src/MultichainAccountService.test.ts @@ -946,7 +946,7 @@ describe('MultichainAccountService', () => { }); describe('removeMultichainAccountWallet', () => { - it('calls KeyringController:removeAccount and removes the wallet', async () => { + it('calls deleteAccount on the EVM provider for an EVM-only wallet and removes the wallet', async () => { const mockEvmAccount = MockAccountBuilder.from(MOCK_HD_ACCOUNT_1) .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) .withGroupIndex(0) @@ -956,22 +956,91 @@ describe('MultichainAccountService', () => { accounts: [mockEvmAccount], }); - // Wallet should exist before removal + // Wallet should exist before removal. expect( service.getMultichainAccountWallet({ entropySource: MOCK_HD_KEYRING_1.metadata.id, }), ).toBeDefined(); - const testAddress = '0xdeadbeef'; await service.removeMultichainAccountWallet( MOCK_HD_KEYRING_1.metadata.id, - testAddress, ); - expect(mocks.KeyringController.removeAccount).toHaveBeenCalledWith( - testAddress, + expect(mocks.EvmAccountProvider.deleteAccount).toHaveBeenCalledTimes(1); + expect(mocks.EvmAccountProvider.deleteAccount).toHaveBeenCalledWith( + mockEvmAccount.id, ); + expect(mocks.SolAccountProvider.deleteAccount).not.toHaveBeenCalled(); + expect(() => + service.getMultichainAccountWallet({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + }), + ).toThrow('Unknown wallet, no wallet matching this entropy source'); + }); + + it('dispatches deleteAccount per provider for a wallet with EVM and Sol accounts', async () => { + 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 { service, mocks } = await setup({ + accounts: [mockEvmAccount, mockSolAccount], + }); + + await service.removeMultichainAccountWallet( + MOCK_HD_KEYRING_1.metadata.id, + ); + + expect(mocks.EvmAccountProvider.deleteAccount).toHaveBeenCalledTimes(1); + expect(mocks.EvmAccountProvider.deleteAccount).toHaveBeenCalledWith( + mockEvmAccount.id, + ); + expect(mocks.SolAccountProvider.deleteAccount).toHaveBeenCalledTimes(1); + expect(mocks.SolAccountProvider.deleteAccount).toHaveBeenCalledWith( + mockSolAccount.id, + ); + expect(() => + service.getMultichainAccountWallet({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + }), + ).toThrow('Unknown wallet, no wallet matching this entropy source'); + }); + + it('continues with remaining accounts and removes the wallet when one provider deleteAccount call throws', async () => { + 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 { service, messenger, mocks } = await setup({ + accounts: [mockEvmAccount, mockSolAccount], + }); + const captureExceptionSpy = jest.spyOn(messenger, 'captureException'); + mocks.SolAccountProvider.deleteAccount.mockRejectedValueOnce( + new Error('snap is unavailable'), + ); + + await service.removeMultichainAccountWallet( + MOCK_HD_KEYRING_1.metadata.id, + ); + + expect(mocks.EvmAccountProvider.deleteAccount).toHaveBeenCalledWith( + mockEvmAccount.id, + ); + expect(mocks.SolAccountProvider.deleteAccount).toHaveBeenCalledWith( + mockSolAccount.id, + ); + expect(captureExceptionSpy).toHaveBeenCalled(); expect(() => service.getMultichainAccountWallet({ entropySource: MOCK_HD_KEYRING_1.metadata.id, @@ -1180,11 +1249,10 @@ describe('MultichainAccountService', () => { await messenger.call( 'MultichainAccountService:removeMultichainAccountWallet', MOCK_HD_KEYRING_1.metadata.id, - MOCK_HD_ACCOUNT_1.address, ); - expect(mocks.KeyringController.removeAccount).toHaveBeenCalledWith( - MOCK_HD_ACCOUNT_1.address, + expect(mocks.EvmAccountProvider.deleteAccount).toHaveBeenCalledWith( + MOCK_HD_ACCOUNT_1.id, ); }); }); @@ -1413,6 +1481,22 @@ describe('MultichainAccountService', () => { (solProvider.isAccountCompatible as jest.Mock).mockReturnValue(false); expect(wrapper.isAccountCompatible(MOCK_HD_ACCOUNT_1)).toBe(false); }); + + it('forwards deleteAccount() to the wrapped provider regardless of enabled state', async () => { + const deleteAccountSpy = jest + .spyOn(solProvider, 'deleteAccount') + .mockResolvedValue(undefined); + + await wrapper.deleteAccount(MOCK_HD_ACCOUNT_1.id); + expect(deleteAccountSpy).toHaveBeenCalledTimes(1); + expect(deleteAccountSpy).toHaveBeenCalledWith(MOCK_HD_ACCOUNT_1.id); + + // Even when disabled, deletion must still go through so that wallet + // removal can clean up snap-backed accounts. + wrapper.setEnabled(false); + await wrapper.deleteAccount(MOCK_HD_ACCOUNT_1.id); + expect(deleteAccountSpy).toHaveBeenCalledTimes(2); + }); }); describe('createMultichainAccountWallet', () => { diff --git a/packages/multichain-account-service/src/providers/AccountProviderWrapper.ts b/packages/multichain-account-service/src/providers/AccountProviderWrapper.ts index 951143a9f3..818e935693 100644 --- a/packages/multichain-account-service/src/providers/AccountProviderWrapper.ts +++ b/packages/multichain-account-service/src/providers/AccountProviderWrapper.ts @@ -131,6 +131,19 @@ export class AccountProviderWrapper extends BaseBip44AccountProvider { return this.provider.createAccounts(options); } + /** + * Forwards to the wrapped provider unconditionally, because deletion must run even + * when the wrapper is disabled, so that wallet-removal flows can clean up + * snap-backed accounts that were created while the provider was previously + * enabled. + * + * @param id - The id of the account to delete. + * @returns A promise that resolves when the account is deleted. + */ + async deleteAccount(id: Bip44Account['id']): Promise { + return this.provider.deleteAccount(id); + } + /** * Implement abstract method: Discover and create accounts, returns empty array when disabled. * diff --git a/packages/multichain-account-service/src/providers/EvmAccountProvider.test.ts b/packages/multichain-account-service/src/providers/EvmAccountProvider.test.ts index 9c668155dd..6a4f30649a 100644 --- a/packages/multichain-account-service/src/providers/EvmAccountProvider.test.ts +++ b/packages/multichain-account-service/src/providers/EvmAccountProvider.test.ts @@ -827,4 +827,42 @@ describe('EvmAccountProvider', () => { expect(await provider.resyncAccounts()).toBeUndefined(); }); + + describe('deleteAccount', () => { + it('selects the keyring by the account entropy source and calls keyring.deleteAccount', async () => { + const { provider, keyring, messenger } = setup({ + accounts: [MOCK_HD_ACCOUNT_1, MOCK_HD_ACCOUNT_2], + }); + const withKeyringV2Spy = jest.fn(async (_, operation) => + operation({ keyring, metadata: keyring.metadata }), + ); + messenger.unregisterActionHandler('KeyringController:withKeyringV2'); + messenger.registerActionHandler( + 'KeyringController:withKeyringV2', + withKeyringV2Spy, + ); + const deleteAccountSpy = jest.spyOn(keyring, 'deleteAccount'); + + await provider.deleteAccount(MOCK_HD_ACCOUNT_1.id); + + expect(withKeyringV2Spy).toHaveBeenCalledWith( + { id: MOCK_HD_ACCOUNT_1.options.entropy.id }, + expect.any(Function), + ); + expect(deleteAccountSpy).toHaveBeenCalledWith(MOCK_HD_ACCOUNT_1.id); + expect(provider.getAccounts()).toStrictEqual([ + asKeyringAccount(MOCK_HD_ACCOUNT_2), + ]); + }); + + it('throws if the account is not tracked by the provider', async () => { + const { provider } = setup({ + accounts: [MOCK_HD_ACCOUNT_1], + }); + + await expect(provider.deleteAccount('unknown-id')).rejects.toThrow( + 'Unable to find account: unknown-id', + ); + }); + }); }); diff --git a/packages/multichain-account-service/src/providers/SnapAccountProvider.test.ts b/packages/multichain-account-service/src/providers/SnapAccountProvider.test.ts index 70a8931ff6..b9526be29c 100644 --- a/packages/multichain-account-service/src/providers/SnapAccountProvider.test.ts +++ b/packages/multichain-account-service/src/providers/SnapAccountProvider.test.ts @@ -958,4 +958,33 @@ describe('SnapAccountProvider', () => { expect(mocks.SnapAccountService.ensureReady).toHaveBeenCalledTimes(1); }); }); + + describe('deleteAccount', () => { + it('forwards to SnapKeyring.removeAccount(address) using the tracked account address', async () => { + const account = MOCK_HD_ACCOUNT_1; + const { provider, keyring, messenger } = setup({ accounts: [account] }); + messenger.registerActionHandler( + 'AccountsController:getAccount', + (id: string) => + [account].find((candidate) => candidate.id === id) as never, + ); + provider.init([account.id]); + + await provider.deleteAccount(account.id); + + expect(keyring.removeAccount).toHaveBeenCalledWith(account.address); + // The provider should no longer track the deleted account. + expect(() => provider.getAccount(account.id)).toThrow( + `Unable to find account: ${account.id}`, + ); + }); + + it('throws if the account is not tracked by the provider', async () => { + const { provider } = setup(); + + await expect(provider.deleteAccount('unknown-id')).rejects.toThrow( + 'Unable to find account: unknown-id', + ); + }); + }); }); diff --git a/packages/multichain-account-service/src/tests/providers.ts b/packages/multichain-account-service/src/tests/providers.ts index 2c92fca4ba..e0e029ca5e 100644 --- a/packages/multichain-account-service/src/tests/providers.ts +++ b/packages/multichain-account-service/src/tests/providers.ts @@ -17,6 +17,7 @@ export type MockAccountProvider = { getAccount: jest.Mock; getAccounts: jest.Mock; createAccounts: jest.Mock; + deleteAccount: jest.Mock; discoverAccounts: jest.Mock; isAccountCompatible: jest.Mock; getName: jest.Mock; @@ -48,6 +49,7 @@ export function makeMockAccountProvider( getAccount: jest.fn(), getAccounts: jest.fn(), createAccounts: jest.fn(), + deleteAccount: jest.fn(), discoverAccounts: jest.fn(), isAccountCompatible: jest.fn(), getName: jest.fn(), From 851abd39fe7577a828df61292f852ce0cc08d7c9 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Mon, 1 Jun 2026 15:05:46 -0400 Subject: [PATCH 03/21] chore: regenerate action types --- .../src/MultichainAccountService-method-action-types.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/multichain-account-service/src/MultichainAccountService-method-action-types.ts b/packages/multichain-account-service/src/MultichainAccountService-method-action-types.ts index fdd968a5fc..89c40575ce 100644 --- a/packages/multichain-account-service/src/MultichainAccountService-method-action-types.ts +++ b/packages/multichain-account-service/src/MultichainAccountService-method-action-types.ts @@ -77,15 +77,18 @@ export type MultichainAccountServiceCreateMultichainAccountWalletAction = { }; /** - * Removes a multichain account wallet. + * Removes a multichain account wallet, deleting all of its accounts across + * every registered provider (EVM and snap-based). * * NOTE: This method should only be called in client code as a revert mechanism. * At the point that this code is called, discovery shouldn't have been triggered. * This is meant to be used in the scenario where a seed phrase backup is not successful. * + * Per-account deletions are best-effort: a single account failure is logged + * via `reportError` but does not abort cleanup of the remaining accounts. + * The wallet is always removed from the service's internal map at the end. + * * @param entropySource - The entropy source of the multichain account wallet. - * @param accountAddress - The address of the account to remove. - * @returns The removed multichain account wallet. */ export type MultichainAccountServiceRemoveMultichainAccountWalletAction = { type: `MultichainAccountService:removeMultichainAccountWallet`; From 06b34a74fd7ae43e16eba3642fc2fb2db6db54ec Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Mon, 1 Jun 2026 15:05:57 -0400 Subject: [PATCH 04/21] chore: add changelog entry --- packages/multichain-account-service/CHANGELOG.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/multichain-account-service/CHANGELOG.md b/packages/multichain-account-service/CHANGELOG.md index 00664d768c..274132b7a1 100644 --- a/packages/multichain-account-service/CHANGELOG.md +++ b/packages/multichain-account-service/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Added `deleteAccount(id)` to the `Bip44AccountProvider` interface and to all built-in providers (`EvmAccountProvider`, `SolAccountProvider`, `BtcAccountProvider`, `TrxAccountProvider`, and the shared `SnapAccountProvider`/`AccountProviderWrapper`) ((#XXXX)[https://github.com/MetaMask/core/pull/XXXX]) + - `EvmAccountProvider.deleteAccount(id)` resolves the account's entropy source and forwards to the v2 HD keyring's `deleteAccount(accountId)`. When this drains the last account on a non-primary HD keyring, the keyring controller automatically prunes the empty keyring. + - `SnapAccountProvider.deleteAccount(id)` resolves the account's address and forwards to the legacy `SnapKeyring.removeAccount(address)` so that the snap and the host keyring stay in sync. + - `AccountProviderWrapper.deleteAccount(id)` forwards unconditionally so that wallet-removal flows can clean up snap-backed accounts even when the wrapper has been disabled. + +### Changed + +- **BREAKING:** `MultichainAccountService.removeMultichainAccountWallet` (and the corresponding `MultichainAccountService:removeMultichainAccountWallet` messenger action) now takes a single `entropySource` argument; the previous `accountAddress` parameter has been removed ((#XXXX)[https://github.com/MetaMask/core/pull/XXXX]) + - The method now iterates every account belonging to the wallet and dispatches `provider.deleteAccount(account.id)` to the matching provider, instead of only removing a single EVM address. + - Per-account deletions are best-effort: a single account's failure is reported via the messenger's `captureException` and does not abort cleanup of the remaining accounts. The wallet is always removed from the service's internal map at the end. + ## [10.0.1] ### Changed From 3b82ce5719f84832461bc982e0338e94cdb73ede Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Mon, 1 Jun 2026 15:10:11 -0400 Subject: [PATCH 05/21] chore: add pr number --- packages/multichain-account-service/CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/multichain-account-service/CHANGELOG.md b/packages/multichain-account-service/CHANGELOG.md index 274132b7a1..3c88cfc199 100644 --- a/packages/multichain-account-service/CHANGELOG.md +++ b/packages/multichain-account-service/CHANGELOG.md @@ -9,14 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Added `deleteAccount(id)` to the `Bip44AccountProvider` interface and to all built-in providers (`EvmAccountProvider`, `SolAccountProvider`, `BtcAccountProvider`, `TrxAccountProvider`, and the shared `SnapAccountProvider`/`AccountProviderWrapper`) ((#XXXX)[https://github.com/MetaMask/core/pull/XXXX]) +- Added `deleteAccount(id)` to the `Bip44AccountProvider` interface and to all built-in providers (`EvmAccountProvider`, `SolAccountProvider`, `BtcAccountProvider`, `TrxAccountProvider`, and the shared `SnapAccountProvider`/`AccountProviderWrapper`) ((#8960)[https://github.com/MetaMask/core/pull/8960]) - `EvmAccountProvider.deleteAccount(id)` resolves the account's entropy source and forwards to the v2 HD keyring's `deleteAccount(accountId)`. When this drains the last account on a non-primary HD keyring, the keyring controller automatically prunes the empty keyring. - `SnapAccountProvider.deleteAccount(id)` resolves the account's address and forwards to the legacy `SnapKeyring.removeAccount(address)` so that the snap and the host keyring stay in sync. - `AccountProviderWrapper.deleteAccount(id)` forwards unconditionally so that wallet-removal flows can clean up snap-backed accounts even when the wrapper has been disabled. ### Changed -- **BREAKING:** `MultichainAccountService.removeMultichainAccountWallet` (and the corresponding `MultichainAccountService:removeMultichainAccountWallet` messenger action) now takes a single `entropySource` argument; the previous `accountAddress` parameter has been removed ((#XXXX)[https://github.com/MetaMask/core/pull/XXXX]) +- **BREAKING:** `MultichainAccountService.removeMultichainAccountWallet` (and the corresponding `MultichainAccountService:removeMultichainAccountWallet` messenger action) now takes a single `entropySource` argument; the previous `accountAddress` parameter has been removed ((#8960)[https://github.com/MetaMask/core/pull/8960]) - The method now iterates every account belonging to the wallet and dispatches `provider.deleteAccount(account.id)` to the matching provider, instead of only removing a single EVM address. - Per-account deletions are best-effort: a single account's failure is reported via the messenger's `captureException` and does not abort cleanup of the remaining accounts. The wallet is always removed from the service's internal map at the end. From 7175953dc9ed004286105246e6c4ed3093c90170 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Mon, 1 Jun 2026 15:23:13 -0400 Subject: [PATCH 06/21] refactor: use provider list as source of truth --- .../src/MultichainAccountService.test.ts | 20 +++++++++++++++- .../src/MultichainAccountService.ts | 23 ++++++++++--------- 2 files changed, 31 insertions(+), 12 deletions(-) diff --git a/packages/multichain-account-service/src/MultichainAccountService.test.ts b/packages/multichain-account-service/src/MultichainAccountService.test.ts index 2d797424a0..4a99058922 100644 --- a/packages/multichain-account-service/src/MultichainAccountService.test.ts +++ b/packages/multichain-account-service/src/MultichainAccountService.test.ts @@ -1,4 +1,4 @@ -import { isBip44Account } from '@metamask/account-api'; +import { Bip44Account, isBip44Account } from '@metamask/account-api'; import { mnemonicPhraseToBytes } from '@metamask/key-tree'; import type { CreateAccountOptions, @@ -161,6 +161,21 @@ function mockAccountProvider( (account: KeyringAccount) => account.type === TrxAccountType.Eoa, ); } + + // Mirror production behavior: a provider's tracked account IDs are only + // those it can manage (per `isAccountCompatible`). `setupBip44AccountProvider` + // initially populates every provider with every account, so we re-filter + // here once the compatibility predicate has been wired up. + const compatiblePredicate = mocks.isAccountCompatible.getMockImplementation(); + if (compatiblePredicate) { + mocks.accounts = new Set( + accounts + .filter((account) => + compatiblePredicate(account as Bip44Account), + ) + .map((account) => account.id), + ); + } } async function setup({ @@ -1005,6 +1020,9 @@ describe('MultichainAccountService', () => { expect(mocks.SolAccountProvider.deleteAccount).toHaveBeenCalledWith( mockSolAccount.id, ); + // Providers that own no accounts for this wallet should be left alone. + expect(mocks.BtcAccountProvider.deleteAccount).not.toHaveBeenCalled(); + expect(mocks.TrxAccountProvider.deleteAccount).not.toHaveBeenCalled(); expect(() => service.getMultichainAccountWallet({ entropySource: 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 7264aef555..a3ba8b26b0 100644 --- a/packages/multichain-account-service/src/MultichainAccountService.ts +++ b/packages/multichain-account-service/src/MultichainAccountService.ts @@ -537,9 +537,12 @@ export class MultichainAccountService { * Removes a multichain account wallet, deleting all of its accounts across * every registered provider (EVM and snap-based). * - * Per-account deletions are best-effort: a single account failure is logged - * via `reportError` but does not abort cleanup of the remaining accounts. - * The wallet is always removed from the service's internal map at the end. + * The deletion iterates providers (the source of truth for their own + * account lists) and filters each provider's accounts to those matching + * the wallet's entropy source. Per-account deletions are best-effort: + * a single account failure is reported via `reportError` but does not + * abort cleanup of the remaining accounts. The wallet is always removed + * from the service's internal map at the end. * * @param entropySource - The entropy source of the multichain account wallet. */ @@ -548,14 +551,12 @@ export class MultichainAccountService { ): Promise { const wallet = this.#getWallet(entropySource); - for (const group of wallet.getMultichainAccountGroups()) { - for (const account of group.getAccounts()) { - const provider = this.#providers.find((candidate) => - candidate.isAccountCompatible(account), - ); - if (!provider) { - continue; - } + for (const provider of this.#providers) { + const owned = provider + .getAccounts() + .filter((account) => account?.options?.entropy?.id === entropySource); + + for (const account of owned) { try { await provider.deleteAccount(account.id); } catch (error) { From 18f418d8c2ed01acbdc7a5e824e5509e438dd93a Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Mon, 1 Jun 2026 15:24:54 -0400 Subject: [PATCH 07/21] fix: regenerate action types --- .../MultichainAccountService-method-action-types.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/multichain-account-service/src/MultichainAccountService-method-action-types.ts b/packages/multichain-account-service/src/MultichainAccountService-method-action-types.ts index 89c40575ce..79378a5cc6 100644 --- a/packages/multichain-account-service/src/MultichainAccountService-method-action-types.ts +++ b/packages/multichain-account-service/src/MultichainAccountService-method-action-types.ts @@ -80,13 +80,12 @@ export type MultichainAccountServiceCreateMultichainAccountWalletAction = { * Removes a multichain account wallet, deleting all of its accounts across * every registered provider (EVM and snap-based). * - * NOTE: This method should only be called in client code as a revert mechanism. - * At the point that this code is called, discovery shouldn't have been triggered. - * This is meant to be used in the scenario where a seed phrase backup is not successful. - * - * Per-account deletions are best-effort: a single account failure is logged - * via `reportError` but does not abort cleanup of the remaining accounts. - * The wallet is always removed from the service's internal map at the end. + * The deletion iterates providers (the source of truth for their own + * account lists) and filters each provider's accounts to those matching + * the wallet's entropy source. Per-account deletions are best-effort: + * a single account failure is reported via `reportError` but does not + * abort cleanup of the remaining accounts. The wallet is always removed + * from the service's internal map at the end. * * @param entropySource - The entropy source of the multichain account wallet. */ From 7ca52422f46543b893519e89965029a46e952688 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Mon, 1 Jun 2026 15:28:31 -0400 Subject: [PATCH 08/21] fix: changelog --- packages/multichain-account-service/CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/multichain-account-service/CHANGELOG.md b/packages/multichain-account-service/CHANGELOG.md index 3c88cfc199..38ccb14544 100644 --- a/packages/multichain-account-service/CHANGELOG.md +++ b/packages/multichain-account-service/CHANGELOG.md @@ -9,14 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Added `deleteAccount(id)` to the `Bip44AccountProvider` interface and to all built-in providers (`EvmAccountProvider`, `SolAccountProvider`, `BtcAccountProvider`, `TrxAccountProvider`, and the shared `SnapAccountProvider`/`AccountProviderWrapper`) ((#8960)[https://github.com/MetaMask/core/pull/8960]) +- Added `deleteAccount(id)` to the `Bip44AccountProvider` interface and to all built-in providers (`EvmAccountProvider`, `SolAccountProvider`, `BtcAccountProvider`, `TrxAccountProvider`, and the shared `SnapAccountProvider`/`AccountProviderWrapper`) ([#8960](https://github.com/MetaMask/core/pull/8960)) - `EvmAccountProvider.deleteAccount(id)` resolves the account's entropy source and forwards to the v2 HD keyring's `deleteAccount(accountId)`. When this drains the last account on a non-primary HD keyring, the keyring controller automatically prunes the empty keyring. - `SnapAccountProvider.deleteAccount(id)` resolves the account's address and forwards to the legacy `SnapKeyring.removeAccount(address)` so that the snap and the host keyring stay in sync. - `AccountProviderWrapper.deleteAccount(id)` forwards unconditionally so that wallet-removal flows can clean up snap-backed accounts even when the wrapper has been disabled. ### Changed -- **BREAKING:** `MultichainAccountService.removeMultichainAccountWallet` (and the corresponding `MultichainAccountService:removeMultichainAccountWallet` messenger action) now takes a single `entropySource` argument; the previous `accountAddress` parameter has been removed ((#8960)[https://github.com/MetaMask/core/pull/8960]) +- **BREAKING:** `MultichainAccountService.removeMultichainAccountWallet` (and the corresponding `MultichainAccountService:removeMultichainAccountWallet` messenger action) now takes a single `entropySource` argument; the previous `accountAddress` parameter has been removed ([#8960](https://github.com/MetaMask/core/pull/8960)) - The method now iterates every account belonging to the wallet and dispatches `provider.deleteAccount(account.id)` to the matching provider, instead of only removing a single EVM address. - Per-account deletions are best-effort: a single account's failure is reported via the messenger's `captureException` and does not abort cleanup of the remaining accounts. The wallet is always removed from the service's internal map at the end. From 3b9422df6519a29695a30fdbafe0e5179e5470ea Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Mon, 1 Jun 2026 15:42:19 -0400 Subject: [PATCH 09/21] fix: cover disabled provider edge case --- .../multichain-account-service/CHANGELOG.md | 2 + .../src/MultichainAccountService.test.ts | 49 +++++++++++++++++++ .../src/MultichainAccountService.ts | 11 ++++- .../src/providers/AccountProviderWrapper.ts | 16 ++++++ 4 files changed, 77 insertions(+), 1 deletion(-) diff --git a/packages/multichain-account-service/CHANGELOG.md b/packages/multichain-account-service/CHANGELOG.md index 38ccb14544..eb4bd759fb 100644 --- a/packages/multichain-account-service/CHANGELOG.md +++ b/packages/multichain-account-service/CHANGELOG.md @@ -13,11 +13,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `EvmAccountProvider.deleteAccount(id)` resolves the account's entropy source and forwards to the v2 HD keyring's `deleteAccount(accountId)`. When this drains the last account on a non-primary HD keyring, the keyring controller automatically prunes the empty keyring. - `SnapAccountProvider.deleteAccount(id)` resolves the account's address and forwards to the legacy `SnapKeyring.removeAccount(address)` so that the snap and the host keyring stay in sync. - `AccountProviderWrapper.deleteAccount(id)` forwards unconditionally so that wallet-removal flows can clean up snap-backed accounts even when the wrapper has been disabled. + - Added a public `wrappedProvider` getter on `AccountProviderWrapper`. This is an escape hatch for cleanup flows (e.g. wallet removal) that need to enumerate the underlying provider's accounts regardless of enabled state, since `getAccounts()` still returns `[]` when the wrapper is disabled. ### Changed - **BREAKING:** `MultichainAccountService.removeMultichainAccountWallet` (and the corresponding `MultichainAccountService:removeMultichainAccountWallet` messenger action) now takes a single `entropySource` argument; the previous `accountAddress` parameter has been removed ([#8960](https://github.com/MetaMask/core/pull/8960)) - The method now iterates every account belonging to the wallet and dispatches `provider.deleteAccount(account.id)` to the matching provider, instead of only removing a single EVM address. + - For wrapped providers, enumeration uses the underlying provider so snap-backed accounts are still deleted when basic functionality is off (the wrapper's `getAccounts()` returns `[]` when disabled). Without this, snap-backed accounts would be orphaned in their underlying keyrings. - Per-account deletions are best-effort: a single account's failure is reported via the messenger's `captureException` and does not abort cleanup of the remaining accounts. The wallet is always removed from the service's internal map at the end. ## [10.0.1] diff --git a/packages/multichain-account-service/src/MultichainAccountService.test.ts b/packages/multichain-account-service/src/MultichainAccountService.test.ts index 4a99058922..f2cbd969d0 100644 --- a/packages/multichain-account-service/src/MultichainAccountService.test.ts +++ b/packages/multichain-account-service/src/MultichainAccountService.test.ts @@ -1065,6 +1065,46 @@ describe('MultichainAccountService', () => { }), ).toThrow('Unknown wallet, no wallet matching this entropy source'); }); + + it('still deletes snap-backed accounts when basic functionality is disabled', async () => { + 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 { service, mocks } = await setup({ + accounts: [mockEvmAccount, mockSolAccount], + }); + + // Turning basic functionality off disables the snap-backed provider + // wrappers (Sol/Btc/Trx). EVM is intentionally left enabled. + await service.setBasicFunctionality(false); + + await service.removeMultichainAccountWallet( + MOCK_HD_KEYRING_1.metadata.id, + ); + + // EVM account must still be deleted. + expect(mocks.EvmAccountProvider.deleteAccount).toHaveBeenCalledWith( + mockEvmAccount.id, + ); + // Regression check: snap-backed account must NOT be orphaned just + // because the wrapper is disabled. The wrapper's `deleteAccount` is + // already designed to forward unconditionally; the consumer must also + // discover the account in the first place. + expect(mocks.SolAccountProvider.deleteAccount).toHaveBeenCalledWith( + mockSolAccount.id, + ); + expect(() => + service.getMultichainAccountWallet({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + }), + ).toThrow('Unknown wallet, no wallet matching this entropy source'); + }); }); describe('actions', () => { @@ -1500,6 +1540,15 @@ describe('MultichainAccountService', () => { expect(wrapper.isAccountCompatible(MOCK_HD_ACCOUNT_1)).toBe(false); }); + it('exposes the wrapped provider via wrappedProvider regardless of enabled state', () => { + expect(wrapper.wrappedProvider).toBe(solProvider); + + // The escape hatch must keep working when the wrapper is disabled, + // since cleanup flows rely on it to discover snap-backed accounts. + wrapper.setEnabled(false); + expect(wrapper.wrappedProvider).toBe(solProvider); + }); + it('forwards deleteAccount() to the wrapped provider regardless of enabled state', async () => { const deleteAccountSpy = jest .spyOn(solProvider, 'deleteAccount') diff --git a/packages/multichain-account-service/src/MultichainAccountService.ts b/packages/multichain-account-service/src/MultichainAccountService.ts index a3ba8b26b0..3a2e00a0ea 100644 --- a/packages/multichain-account-service/src/MultichainAccountService.ts +++ b/packages/multichain-account-service/src/MultichainAccountService.ts @@ -552,7 +552,16 @@ export class MultichainAccountService { const wallet = this.#getWallet(entropySource); for (const provider of this.#providers) { - const owned = provider + // For wrapped providers, enumerate via the underlying provider so we + // also see accounts when the wrapper has been disabled (i.e. basic + // functionality is off). The wrapper's `deleteAccount` itself forwards + // unconditionally, but its `getAccounts()` returns `[]` when disabled, + // which would otherwise leave snap-backed accounts orphaned in their + // underlying keyrings. + const source = isAccountProviderWrapper(provider) + ? provider.wrappedProvider + : provider; + const owned = source .getAccounts() .filter((account) => account?.options?.entropy?.id === entropySource); diff --git a/packages/multichain-account-service/src/providers/AccountProviderWrapper.ts b/packages/multichain-account-service/src/providers/AccountProviderWrapper.ts index 818e935693..2bacfbc3d1 100644 --- a/packages/multichain-account-service/src/providers/AccountProviderWrapper.ts +++ b/packages/multichain-account-service/src/providers/AccountProviderWrapper.ts @@ -45,6 +45,22 @@ export class AccountProviderWrapper extends BaseBip44AccountProvider { this.provider.init(accounts); } + /** + * Returns the underlying (unwrapped) provider. + * + * Most callers should go through the wrapper's public surface so that the + * `disabled` gating is respected. This escape hatch is reserved for + * cleanup flows (e.g. wallet removal) that must see the wrapped + * provider's accounts regardless of enabled state, so that snap-backed + * accounts created while the provider was enabled can still be deleted + * after it has been disabled. + * + * @returns The wrapped provider instance. + */ + get wrappedProvider(): BaseBip44AccountProvider { + return this.provider; + } + /** * Set the enabled state for this provider. * From 008d93685253b38f772fe273a0bc7807678f34e2 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Tue, 2 Jun 2026 11:19:39 -0400 Subject: [PATCH 10/21] chore: update changelog --- packages/multichain-account-service/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/multichain-account-service/CHANGELOG.md b/packages/multichain-account-service/CHANGELOG.md index eb4bd759fb..dae7972786 100644 --- a/packages/multichain-account-service/CHANGELOG.md +++ b/packages/multichain-account-service/CHANGELOG.md @@ -20,7 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **BREAKING:** `MultichainAccountService.removeMultichainAccountWallet` (and the corresponding `MultichainAccountService:removeMultichainAccountWallet` messenger action) now takes a single `entropySource` argument; the previous `accountAddress` parameter has been removed ([#8960](https://github.com/MetaMask/core/pull/8960)) - The method now iterates every account belonging to the wallet and dispatches `provider.deleteAccount(account.id)` to the matching provider, instead of only removing a single EVM address. - For wrapped providers, enumeration uses the underlying provider so snap-backed accounts are still deleted when basic functionality is off (the wrapper's `getAccounts()` returns `[]` when disabled). Without this, snap-backed accounts would be orphaned in their underlying keyrings. - - Per-account deletions are best-effort: a single account's failure is reported via the messenger's `captureException` and does not abort cleanup of the remaining accounts. The wallet is always removed from the service's internal map at the end. + - Per-account deletions are best-effort: a single account's failure does not abort cleanup of the remaining accounts. If one or more deletions fail, a single aggregated error is reported via the messenger's `captureException` with the per-account failure details (`provider`, `accountId`, error message, stack) in its `context`. The wallet is always removed from the service's internal map at the end. ## [10.0.1] From 065838bb7430ec69ea71578665fb9ebee6fc97b1 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Tue, 2 Jun 2026 11:24:03 -0400 Subject: [PATCH 11/21] refactor: dry some repeated patterns --- .../src/MultichainAccountService.test.ts | 154 ++++++++++++++---- .../src/MultichainAccountService.ts | 76 +++++++-- .../src/providers/SnapAccountProvider.test.ts | 3 +- .../multichain-account-service/src/utils.ts | 29 +++- 4 files changed, 213 insertions(+), 49 deletions(-) diff --git a/packages/multichain-account-service/src/MultichainAccountService.test.ts b/packages/multichain-account-service/src/MultichainAccountService.test.ts index f2cbd969d0..1cf7a3bee0 100644 --- a/packages/multichain-account-service/src/MultichainAccountService.test.ts +++ b/packages/multichain-account-service/src/MultichainAccountService.test.ts @@ -17,7 +17,10 @@ import type { KeyringObject } from '@metamask/keyring-controller'; import { traceFallback } from './analytics'; import { isPerfEnabled, withLocalPerfTrace } from './analytics/perf'; -import type { MultichainAccountServiceOptions } from './MultichainAccountService'; +import type { + MultichainAccountServiceOptions, + RemoveMultichainAccountWalletFailureContext, +} from './MultichainAccountService'; import { MultichainAccountService } from './MultichainAccountService'; import type { Bip44AccountProvider } from './providers'; import { TimeoutError } from './providers'; @@ -58,6 +61,7 @@ import { setupBip44AccountProvider, } from './tests'; import type { MultichainAccountServiceMessenger } from './types'; +import type { SentryError } from './utils'; // Mock perf helpers so tests can control isPerfEnabled() without setting DEBUG env var. jest.mock('./analytics/perf', () => ({ @@ -961,12 +965,17 @@ describe('MultichainAccountService', () => { }); describe('removeMultichainAccountWallet', () => { - it('calls deleteAccount on the EVM provider for an EVM-only wallet and removes the wallet', async () => { - const mockEvmAccount = MockAccountBuilder.from(MOCK_HD_ACCOUNT_1) + const makeWalletAccount = ( + template: Account, + ): Account => + MockAccountBuilder.from(template) .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) .withGroupIndex(0) .get(); + it('calls deleteAccount on the EVM provider for an EVM-only wallet and removes the wallet', async () => { + const mockEvmAccount = makeWalletAccount(MOCK_HD_ACCOUNT_1); + const { service, mocks } = await setup({ accounts: [mockEvmAccount], }); @@ -995,14 +1004,8 @@ describe('MultichainAccountService', () => { }); it('dispatches deleteAccount per provider for a wallet with EVM and Sol accounts', async () => { - 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 mockEvmAccount = makeWalletAccount(MOCK_HD_ACCOUNT_1); + const mockSolAccount = makeWalletAccount(MOCK_SOL_ACCOUNT_1); const { service, mocks } = await setup({ accounts: [mockEvmAccount, mockSolAccount], @@ -1031,14 +1034,8 @@ describe('MultichainAccountService', () => { }); it('continues with remaining accounts and removes the wallet when one provider deleteAccount call throws', async () => { - 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 mockEvmAccount = makeWalletAccount(MOCK_HD_ACCOUNT_1); + const mockSolAccount = makeWalletAccount(MOCK_SOL_ACCOUNT_1); const { service, messenger, mocks } = await setup({ accounts: [mockEvmAccount, mockSolAccount], @@ -1058,7 +1055,25 @@ describe('MultichainAccountService', () => { expect(mocks.SolAccountProvider.deleteAccount).toHaveBeenCalledWith( mockSolAccount.id, ); - expect(captureExceptionSpy).toHaveBeenCalled(); + // A single aggregated Sentry report is fired even though only one + // account failed: the wallet-removal action is treated as one + // coherent incident. + expect(captureExceptionSpy).toHaveBeenCalledTimes(1); + const sentryError = captureExceptionSpy.mock + .calls[0]?.[0] as SentryError; + expect(sentryError.message).toBe( + 'Failed to delete one or more accounts during wallet removal', + ); + expect(sentryError.context?.entropySource).toBe( + MOCK_HD_KEYRING_1.metadata.id, + ); + expect(sentryError.context?.failureCount).toBe(1); + expect(sentryError.context?.failures).toStrictEqual([ + expect.objectContaining({ + provider: SOL_ACCOUNT_PROVIDER_NAME, + accountId: mockSolAccount.id, + }), + ]); expect(() => service.getMultichainAccountWallet({ entropySource: MOCK_HD_KEYRING_1.metadata.id, @@ -1066,15 +1081,98 @@ describe('MultichainAccountService', () => { ).toThrow('Unknown wallet, no wallet matching this entropy source'); }); + it('aggregates multiple per-account failures into one report', async () => { + const mockEvmAccount = makeWalletAccount(MOCK_HD_ACCOUNT_1); + const mockSolAccount = makeWalletAccount(MOCK_SOL_ACCOUNT_1); + + const { service, messenger, mocks } = await setup({ + accounts: [mockEvmAccount, mockSolAccount], + }); + const captureExceptionSpy = jest.spyOn(messenger, 'captureException'); + mocks.EvmAccountProvider.deleteAccount.mockRejectedValueOnce( + new Error('evm keyring locked'), + ); + mocks.SolAccountProvider.deleteAccount.mockRejectedValueOnce( + new Error('snap is unavailable'), + ); + + await service.removeMultichainAccountWallet( + MOCK_HD_KEYRING_1.metadata.id, + ); + + expect(captureExceptionSpy).toHaveBeenCalledTimes(1); + const sentryError = captureExceptionSpy.mock + .calls[0]?.[0] as SentryError; + expect(sentryError.context?.failureCount).toBe(2); + expect(sentryError.context?.failures).toStrictEqual( + expect.arrayContaining([ + expect.objectContaining({ + provider: EVM_ACCOUNT_PROVIDER_NAME, + accountId: mockEvmAccount.id, + error: 'evm keyring locked', + }), + expect.objectContaining({ + provider: SOL_ACCOUNT_PROVIDER_NAME, + accountId: mockSolAccount.id, + error: 'snap is unavailable', + }), + ]), + ); + expect(() => + service.getMultichainAccountWallet({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + }), + ).toThrow('Unknown wallet, no wallet matching this entropy source'); + }); + + it('handles non-Error rejections in the aggregated report', async () => { + const mockEvmAccount = makeWalletAccount(MOCK_HD_ACCOUNT_1); + + const { service, messenger, mocks } = await setup({ + accounts: [mockEvmAccount], + }); + const captureExceptionSpy = jest.spyOn(messenger, 'captureException'); + // Real keyring code only rejects with `Error`s, but downstream + // messenger handlers can in principle reject with any value. The + // aggregator should handle that without throwing. + mocks.EvmAccountProvider.deleteAccount.mockRejectedValueOnce( + 'plain string failure', + ); + + await service.removeMultichainAccountWallet( + MOCK_HD_KEYRING_1.metadata.id, + ); + + expect(captureExceptionSpy).toHaveBeenCalledTimes(1); + const sentryError = captureExceptionSpy.mock + .calls[0]?.[0] as SentryError; + expect(sentryError.context?.failures[0]).toStrictEqual( + expect.objectContaining({ + error: 'plain string failure', + stack: undefined, + }), + ); + }); + + it('does not call captureException when all deletes succeed', async () => { + const mockEvmAccount = makeWalletAccount(MOCK_HD_ACCOUNT_1); + const mockSolAccount = makeWalletAccount(MOCK_SOL_ACCOUNT_1); + + const { service, messenger } = await setup({ + accounts: [mockEvmAccount, mockSolAccount], + }); + const captureExceptionSpy = jest.spyOn(messenger, 'captureException'); + + await service.removeMultichainAccountWallet( + MOCK_HD_KEYRING_1.metadata.id, + ); + + expect(captureExceptionSpy).not.toHaveBeenCalled(); + }); + it('still deletes snap-backed accounts when basic functionality is disabled', async () => { - 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 mockEvmAccount = makeWalletAccount(MOCK_HD_ACCOUNT_1); + const mockSolAccount = makeWalletAccount(MOCK_SOL_ACCOUNT_1); const { service, mocks } = await setup({ accounts: [mockEvmAccount, mockSolAccount], diff --git a/packages/multichain-account-service/src/MultichainAccountService.ts b/packages/multichain-account-service/src/MultichainAccountService.ts index 3a2e00a0ea..e77cf021d9 100644 --- a/packages/multichain-account-service/src/MultichainAccountService.ts +++ b/packages/multichain-account-service/src/MultichainAccountService.ts @@ -44,6 +44,32 @@ import type { MultichainAccountServiceConfig, MultichainAccountServiceMessenger, } from './types'; +import { toErrorMessage } from './utils'; + +/** + * Per-account failure detail attached to the aggregated Sentry report + * produced by {@link MultichainAccountService.removeMultichainAccountWallet}. + * + * Names the on-the-wire shape so consumers reading the Sentry context (and + * tests asserting on it) have one place to look. + */ +export type RemoveMultichainAccountWalletFailure = { + provider: string; + accountId: Bip44Account['id']; + error: string; + stack?: string; +}; + +/** + * Aggregated context payload attached to the Sentry report produced by + * {@link MultichainAccountService.removeMultichainAccountWallet} when one + * or more per-account deletions fail. + */ +export type RemoveMultichainAccountWalletFailureContext = { + entropySource: EntropySourceId; + failureCount: number; + failures: RemoveMultichainAccountWalletFailure[]; +}; export const serviceName = 'MultichainAccountService'; @@ -539,10 +565,12 @@ export class MultichainAccountService { * * The deletion iterates providers (the source of truth for their own * account lists) and filters each provider's accounts to those matching - * the wallet's entropy source. Per-account deletions are best-effort: - * a single account failure is reported via `reportError` but does not - * abort cleanup of the remaining accounts. The wallet is always removed - * from the service's internal map at the end. + * the wallet's entropy source. Per-account deletions are best-effort: a + * single account failure does not abort cleanup of the remaining + * accounts. If one or more deletions fail, a single aggregated error is + * reported via `reportError` with all per-account failure details in its + * context. The wallet is always removed from the service's internal map + * at the end. * * @param entropySource - The entropy source of the multichain account wallet. */ @@ -550,6 +578,11 @@ export class MultichainAccountService { entropySource: EntropySourceId, ): Promise { const wallet = this.#getWallet(entropySource); + const failures: { + provider: string; + accountId: Bip44Account['id']; + error: unknown; + }[] = []; for (const provider of this.#providers) { // For wrapped providers, enumerate via the underlying provider so we @@ -569,19 +602,38 @@ export class MultichainAccountService { try { await provider.deleteAccount(account.id); } catch (error) { - reportError( - this.#messenger, - `Unable to delete account during wallet removal`, + failures.push({ + provider: provider.getName(), + accountId: account.id, error, - { - provider: provider.getName(), - accountId: account.id, - }, - ); + }); } } } + if (failures.length > 0) { + // One aggregated report per wallet-removal action: keeps the Sentry + // message stable for grouping while still surfacing every per-account + // failure in `context`. The shape is pinned by + // `RemoveMultichainAccountWalletFailureContext`. + const context: RemoveMultichainAccountWalletFailureContext = { + entropySource, + failureCount: failures.length, + failures: failures.map(({ provider, accountId, error }) => ({ + provider, + accountId, + error: toErrorMessage(error), + stack: error instanceof Error ? error.stack : undefined, + })), + }; + reportError( + this.#messenger, + `Failed to delete one or more accounts during wallet removal`, + new Error('Wallet removal partially failed'), + context, + ); + } + this.#wallets.delete(wallet.id); } diff --git a/packages/multichain-account-service/src/providers/SnapAccountProvider.test.ts b/packages/multichain-account-service/src/providers/SnapAccountProvider.test.ts index b9526be29c..949aa650a5 100644 --- a/packages/multichain-account-service/src/providers/SnapAccountProvider.test.ts +++ b/packages/multichain-account-service/src/providers/SnapAccountProvider.test.ts @@ -965,8 +965,7 @@ describe('SnapAccountProvider', () => { const { provider, keyring, messenger } = setup({ accounts: [account] }); messenger.registerActionHandler( 'AccountsController:getAccount', - (id: string) => - [account].find((candidate) => candidate.id === id) as never, + (id) => (id === account.id ? (account as InternalAccount) : undefined), ); provider.init([account.id]); diff --git a/packages/multichain-account-service/src/utils.ts b/packages/multichain-account-service/src/utils.ts index bfc3aee312..7c32687995 100644 --- a/packages/multichain-account-service/src/utils.ts +++ b/packages/multichain-account-service/src/utils.ts @@ -47,6 +47,22 @@ export function assertGroupIndexIsValid( } } +/** + * Augmented `Error` shape produced by {@link createSentryError}. The runtime + * value carries a `cause` and (optionally) a structured `context` payload + * that downstream Sentry tooling can read. + * + * The `TContext` type parameter narrows the shape of `context` for callers + * that know what they put in — most useful in tests when asserting on a + * captured error. + */ +export type SentryError< + TContext extends Record = Record, +> = Error & { + cause: Error; + context?: TContext; +}; + /** * Creates a Sentry error from an error message, an inner error and a context. * @@ -58,15 +74,14 @@ export function assertGroupIndexIsValid( * @param context - The context to add to the Sentry error. * @returns A Sentry error. */ -export const createSentryError = ( +export const createSentryError = < + TContext extends Record = Record, +>( message: string, innerError: Error, - context?: Record, -): Error => { - const error = new Error(message) as Error & { - cause: Error; - context: typeof context; - }; + context?: TContext, +): SentryError => { + const error = new Error(message) as SentryError; error.cause = innerError; if (context) { error.context = context; From 38257bf67eca715422baad53276d882a6b89aeb4 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Tue, 2 Jun 2026 11:28:02 -0400 Subject: [PATCH 12/21] fix: lint --- .../src/providers/SnapAccountProvider.test.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/multichain-account-service/src/providers/SnapAccountProvider.test.ts b/packages/multichain-account-service/src/providers/SnapAccountProvider.test.ts index 949aa650a5..bfb8a0415c 100644 --- a/packages/multichain-account-service/src/providers/SnapAccountProvider.test.ts +++ b/packages/multichain-account-service/src/providers/SnapAccountProvider.test.ts @@ -963,9 +963,8 @@ describe('SnapAccountProvider', () => { it('forwards to SnapKeyring.removeAccount(address) using the tracked account address', async () => { const account = MOCK_HD_ACCOUNT_1; const { provider, keyring, messenger } = setup({ accounts: [account] }); - messenger.registerActionHandler( - 'AccountsController:getAccount', - (id) => (id === account.id ? (account as InternalAccount) : undefined), + messenger.registerActionHandler('AccountsController:getAccount', (id) => + id === account.id ? (account as InternalAccount) : undefined, ); provider.init([account.id]); From d01550f9acaedfcdb6ae735be11f926f324d7565 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Tue, 2 Jun 2026 11:48:58 -0400 Subject: [PATCH 13/21] chore: regenerate action method types --- .../MultichainAccountService-method-action-types.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/multichain-account-service/src/MultichainAccountService-method-action-types.ts b/packages/multichain-account-service/src/MultichainAccountService-method-action-types.ts index 79378a5cc6..2a416cb884 100644 --- a/packages/multichain-account-service/src/MultichainAccountService-method-action-types.ts +++ b/packages/multichain-account-service/src/MultichainAccountService-method-action-types.ts @@ -82,10 +82,12 @@ export type MultichainAccountServiceCreateMultichainAccountWalletAction = { * * The deletion iterates providers (the source of truth for their own * account lists) and filters each provider's accounts to those matching - * the wallet's entropy source. Per-account deletions are best-effort: - * a single account failure is reported via `reportError` but does not - * abort cleanup of the remaining accounts. The wallet is always removed - * from the service's internal map at the end. + * the wallet's entropy source. Per-account deletions are best-effort: a + * single account failure does not abort cleanup of the remaining + * accounts. If one or more deletions fail, a single aggregated error is + * reported via `reportError` with all per-account failure details in its + * context. The wallet is always removed from the service's internal map + * at the end. * * @param entropySource - The entropy source of the multichain account wallet. */ From 2b4271c56052c056eacb8a9af6e0649246319169 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Thu, 4 Jun 2026 09:17:54 -0400 Subject: [PATCH 14/21] refactor: remove optional chaining --- .../multichain-account-service/src/MultichainAccountService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/multichain-account-service/src/MultichainAccountService.ts b/packages/multichain-account-service/src/MultichainAccountService.ts index e77cf021d9..eb91e95524 100644 --- a/packages/multichain-account-service/src/MultichainAccountService.ts +++ b/packages/multichain-account-service/src/MultichainAccountService.ts @@ -596,7 +596,7 @@ export class MultichainAccountService { : provider; const owned = source .getAccounts() - .filter((account) => account?.options?.entropy?.id === entropySource); + .filter((account) => account.options.entropy.id === entropySource); for (const account of owned) { try { From 3f52fade8f81ae8a5f33d86e2d1314797cf3cf11 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Thu, 4 Jun 2026 09:21:06 -0400 Subject: [PATCH 15/21] refactor: rename method to unwrap --- packages/multichain-account-service/CHANGELOG.md | 2 +- .../src/MultichainAccountService.test.ts | 6 +++--- .../src/MultichainAccountService.ts | 2 +- .../src/providers/AccountProviderWrapper.ts | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/multichain-account-service/CHANGELOG.md b/packages/multichain-account-service/CHANGELOG.md index cf1a4d1fba..97135c67fc 100644 --- a/packages/multichain-account-service/CHANGELOG.md +++ b/packages/multichain-account-service/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `EvmAccountProvider.deleteAccount(id)` resolves the account's entropy source and forwards to the v2 HD keyring's `deleteAccount(accountId)`. When this drains the last account on a non-primary HD keyring, the keyring controller automatically prunes the empty keyring. - `SnapAccountProvider.deleteAccount(id)` resolves the account's address and forwards to the legacy `SnapKeyring.removeAccount(address)` so that the snap and the host keyring stay in sync. - `AccountProviderWrapper.deleteAccount(id)` forwards unconditionally so that wallet-removal flows can clean up snap-backed accounts even when the wrapper has been disabled. - - Added a public `wrappedProvider` getter on `AccountProviderWrapper`. This is an escape hatch for cleanup flows (e.g. wallet removal) that need to enumerate the underlying provider's accounts regardless of enabled state, since `getAccounts()` still returns `[]` when the wrapper is disabled. + - Added a public `unwrap` method on `AccountProviderWrapper`. This is an escape hatch for cleanup flows (e.g. wallet removal) that need to enumerate the underlying provider's accounts regardless of enabled state, since `getAccounts()` still returns `[]` when the wrapper is disabled. ### Changed diff --git a/packages/multichain-account-service/src/MultichainAccountService.test.ts b/packages/multichain-account-service/src/MultichainAccountService.test.ts index 1cf7a3bee0..16904ed0d5 100644 --- a/packages/multichain-account-service/src/MultichainAccountService.test.ts +++ b/packages/multichain-account-service/src/MultichainAccountService.test.ts @@ -1638,13 +1638,13 @@ describe('MultichainAccountService', () => { expect(wrapper.isAccountCompatible(MOCK_HD_ACCOUNT_1)).toBe(false); }); - it('exposes the wrapped provider via wrappedProvider regardless of enabled state', () => { - expect(wrapper.wrappedProvider).toBe(solProvider); + it('exposes the wrapped provider via unwrap() regardless of enabled state', () => { + expect(wrapper.unwrap()).toBe(solProvider); // The escape hatch must keep working when the wrapper is disabled, // since cleanup flows rely on it to discover snap-backed accounts. wrapper.setEnabled(false); - expect(wrapper.wrappedProvider).toBe(solProvider); + expect(wrapper.unwrap()).toBe(solProvider); }); it('forwards deleteAccount() to the wrapped provider regardless of enabled state', async () => { diff --git a/packages/multichain-account-service/src/MultichainAccountService.ts b/packages/multichain-account-service/src/MultichainAccountService.ts index eb91e95524..aa93826b8e 100644 --- a/packages/multichain-account-service/src/MultichainAccountService.ts +++ b/packages/multichain-account-service/src/MultichainAccountService.ts @@ -592,7 +592,7 @@ export class MultichainAccountService { // which would otherwise leave snap-backed accounts orphaned in their // underlying keyrings. const source = isAccountProviderWrapper(provider) - ? provider.wrappedProvider + ? provider.unwrap() : provider; const owned = source .getAccounts() diff --git a/packages/multichain-account-service/src/providers/AccountProviderWrapper.ts b/packages/multichain-account-service/src/providers/AccountProviderWrapper.ts index 2bacfbc3d1..00518b0a0b 100644 --- a/packages/multichain-account-service/src/providers/AccountProviderWrapper.ts +++ b/packages/multichain-account-service/src/providers/AccountProviderWrapper.ts @@ -57,7 +57,7 @@ export class AccountProviderWrapper extends BaseBip44AccountProvider { * * @returns The wrapped provider instance. */ - get wrappedProvider(): BaseBip44AccountProvider { + unwrap(): BaseBip44AccountProvider { return this.provider; } From 4634931e447609b714d9aeffb45323ffbb21951c Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Thu, 4 Jun 2026 09:22:23 -0400 Subject: [PATCH 16/21] refactor: reuse type --- .../src/MultichainAccountService.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/multichain-account-service/src/MultichainAccountService.ts b/packages/multichain-account-service/src/MultichainAccountService.ts index aa93826b8e..a595021f9e 100644 --- a/packages/multichain-account-service/src/MultichainAccountService.ts +++ b/packages/multichain-account-service/src/MultichainAccountService.ts @@ -578,11 +578,7 @@ export class MultichainAccountService { entropySource: EntropySourceId, ): Promise { const wallet = this.#getWallet(entropySource); - const failures: { - provider: string; - accountId: Bip44Account['id']; - error: unknown; - }[] = []; + const failures: RemoveMultichainAccountWalletFailure[] = []; for (const provider of this.#providers) { // For wrapped providers, enumerate via the underlying provider so we From 3140e760587f1cc285c33417c9ca61f956d9b11c Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Thu, 4 Jun 2026 09:25:33 -0400 Subject: [PATCH 17/21] fix: update error type --- .../multichain-account-service/src/MultichainAccountService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/multichain-account-service/src/MultichainAccountService.ts b/packages/multichain-account-service/src/MultichainAccountService.ts index a595021f9e..73bffd3b72 100644 --- a/packages/multichain-account-service/src/MultichainAccountService.ts +++ b/packages/multichain-account-service/src/MultichainAccountService.ts @@ -56,7 +56,7 @@ import { toErrorMessage } from './utils'; export type RemoveMultichainAccountWalletFailure = { provider: string; accountId: Bip44Account['id']; - error: string; + error: unknown; stack?: string; }; From 16424b4413855904364becdc88adb4db154662be Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Thu, 4 Jun 2026 10:30:27 -0400 Subject: [PATCH 18/21] refactor: remove some extra props --- .../src/MultichainAccountService.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/multichain-account-service/src/MultichainAccountService.ts b/packages/multichain-account-service/src/MultichainAccountService.ts index 73bffd3b72..b1e67ca266 100644 --- a/packages/multichain-account-service/src/MultichainAccountService.ts +++ b/packages/multichain-account-service/src/MultichainAccountService.ts @@ -57,7 +57,6 @@ export type RemoveMultichainAccountWalletFailure = { provider: string; accountId: Bip44Account['id']; error: unknown; - stack?: string; }; /** @@ -67,7 +66,6 @@ export type RemoveMultichainAccountWalletFailure = { */ export type RemoveMultichainAccountWalletFailureContext = { entropySource: EntropySourceId; - failureCount: number; failures: RemoveMultichainAccountWalletFailure[]; }; @@ -614,12 +612,10 @@ export class MultichainAccountService { // `RemoveMultichainAccountWalletFailureContext`. const context: RemoveMultichainAccountWalletFailureContext = { entropySource, - failureCount: failures.length, failures: failures.map(({ provider, accountId, error }) => ({ provider, accountId, error: toErrorMessage(error), - stack: error instanceof Error ? error.stack : undefined, })), }; reportError( From 5fc8a325e3d351e8c5792a5e6e19469dcebebac8 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Thu, 4 Jun 2026 10:39:05 -0400 Subject: [PATCH 19/21] test: update tests --- .../src/MultichainAccountService.test.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/multichain-account-service/src/MultichainAccountService.test.ts b/packages/multichain-account-service/src/MultichainAccountService.test.ts index 16904ed0d5..2b06546fab 100644 --- a/packages/multichain-account-service/src/MultichainAccountService.test.ts +++ b/packages/multichain-account-service/src/MultichainAccountService.test.ts @@ -1067,7 +1067,6 @@ describe('MultichainAccountService', () => { expect(sentryError.context?.entropySource).toBe( MOCK_HD_KEYRING_1.metadata.id, ); - expect(sentryError.context?.failureCount).toBe(1); expect(sentryError.context?.failures).toStrictEqual([ expect.objectContaining({ provider: SOL_ACCOUNT_PROVIDER_NAME, @@ -1103,7 +1102,6 @@ describe('MultichainAccountService', () => { expect(captureExceptionSpy).toHaveBeenCalledTimes(1); const sentryError = captureExceptionSpy.mock .calls[0]?.[0] as SentryError; - expect(sentryError.context?.failureCount).toBe(2); expect(sentryError.context?.failures).toStrictEqual( expect.arrayContaining([ expect.objectContaining({ @@ -1149,7 +1147,6 @@ describe('MultichainAccountService', () => { expect(sentryError.context?.failures[0]).toStrictEqual( expect.objectContaining({ error: 'plain string failure', - stack: undefined, }), ); }); From fe10e4c2901e9752a540ca47b3fe87077fcb0246 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Thu, 4 Jun 2026 11:06:34 -0400 Subject: [PATCH 20/21] refactor: address bugbot comment --- .../src/MultichainAccountService.test.ts | 44 +++++++++++++++ .../src/MultichainAccountService.ts | 53 ++++++++++++------- 2 files changed, 78 insertions(+), 19 deletions(-) diff --git a/packages/multichain-account-service/src/MultichainAccountService.test.ts b/packages/multichain-account-service/src/MultichainAccountService.test.ts index 2b06546fab..9baa92bcd7 100644 --- a/packages/multichain-account-service/src/MultichainAccountService.test.ts +++ b/packages/multichain-account-service/src/MultichainAccountService.test.ts @@ -1123,6 +1123,50 @@ describe('MultichainAccountService', () => { ).toThrow('Unknown wallet, no wallet matching this entropy source'); }); + it('continues with remaining providers and removes the wallet when enumerating one provider throws', async () => { + const mockEvmAccount = makeWalletAccount(MOCK_HD_ACCOUNT_1); + const mockSolAccount = makeWalletAccount(MOCK_SOL_ACCOUNT_1); + + const { service, messenger, mocks } = await setup({ + accounts: [mockEvmAccount, mockSolAccount], + }); + const captureExceptionSpy = jest.spyOn(messenger, 'captureException'); + // Enumerating the Sol provider's accounts throws before any specific + // account can be targeted. This must not abort cleanup of the other + // providers nor skip the always-remove step. + mocks.SolAccountProvider.getAccounts.mockImplementationOnce(() => { + throw new Error('snap keyring unavailable'); + }); + + await service.removeMultichainAccountWallet( + MOCK_HD_KEYRING_1.metadata.id, + ); + + // EVM account must still be deleted even though the Sol provider failed + // to enumerate. + expect(mocks.EvmAccountProvider.deleteAccount).toHaveBeenCalledWith( + mockEvmAccount.id, + ); + // The Sol provider failure is reported as a provider-level failure with + // no specific `accountId`. + expect(captureExceptionSpy).toHaveBeenCalledTimes(1); + const sentryError = captureExceptionSpy.mock + .calls[0]?.[0] as SentryError; + expect(sentryError.context?.failures).toStrictEqual([ + { + provider: SOL_ACCOUNT_PROVIDER_NAME, + accountId: undefined, + error: 'snap keyring unavailable', + }, + ]); + // The wallet is always removed at the end. + expect(() => + service.getMultichainAccountWallet({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + }), + ).toThrow('Unknown wallet, no wallet matching this entropy source'); + }); + it('handles non-Error rejections in the aggregated report', async () => { const mockEvmAccount = makeWalletAccount(MOCK_HD_ACCOUNT_1); diff --git a/packages/multichain-account-service/src/MultichainAccountService.ts b/packages/multichain-account-service/src/MultichainAccountService.ts index b1e67ca266..485a4d14cf 100644 --- a/packages/multichain-account-service/src/MultichainAccountService.ts +++ b/packages/multichain-account-service/src/MultichainAccountService.ts @@ -55,7 +55,9 @@ import { toErrorMessage } from './utils'; */ export type RemoveMultichainAccountWalletFailure = { provider: string; - accountId: Bip44Account['id']; + // Omitted for provider-level failures (e.g. enumerating a provider's + // accounts threw before any specific account could be targeted). + accountId?: Bip44Account['id']; error: unknown; }; @@ -563,12 +565,12 @@ export class MultichainAccountService { * * The deletion iterates providers (the source of truth for their own * account lists) and filters each provider's accounts to those matching - * the wallet's entropy source. Per-account deletions are best-effort: a - * single account failure does not abort cleanup of the remaining - * accounts. If one or more deletions fail, a single aggregated error is - * reported via `reportError` with all per-account failure details in its - * context. The wallet is always removed from the service's internal map - * at the end. + * the wallet's entropy source. Cleanup is best-effort end-to-end: neither + * a single account deletion failure nor a failure to enumerate a given + * provider's accounts aborts cleanup of the remaining providers. If one or + * more operations fail, a single aggregated error is reported via + * `reportError` with all per-failure details in its context. The wallet is + * always removed from the service's internal map at the end. * * @param entropySource - The entropy source of the multichain account wallet. */ @@ -579,18 +581,31 @@ export class MultichainAccountService { const failures: RemoveMultichainAccountWalletFailure[] = []; for (const provider of this.#providers) { - // For wrapped providers, enumerate via the underlying provider so we - // also see accounts when the wrapper has been disabled (i.e. basic - // functionality is off). The wrapper's `deleteAccount` itself forwards - // unconditionally, but its `getAccounts()` returns `[]` when disabled, - // which would otherwise leave snap-backed accounts orphaned in their - // underlying keyrings. - const source = isAccountProviderWrapper(provider) - ? provider.unwrap() - : provider; - const owned = source - .getAccounts() - .filter((account) => account.options.entropy.id === entropySource); + // Enumerating a provider's owned accounts can itself throw (e.g. + // `unwrap()`, `getAccounts()`, or reading account options). Catch it as + // a provider-level failure and move on so one bad provider does not + // abort cleanup of the others or skip the always-remove step below. + let owned: Bip44Account[]; + try { + // For wrapped providers, enumerate via the underlying provider so we + // also see accounts when the wrapper has been disabled (i.e. basic + // functionality is off). The wrapper's `deleteAccount` itself forwards + // unconditionally, but its `getAccounts()` returns `[]` when disabled, + // which would otherwise leave snap-backed accounts orphaned in their + // underlying keyrings. + const source = isAccountProviderWrapper(provider) + ? provider.unwrap() + : provider; + owned = source + .getAccounts() + .filter((account) => account.options.entropy.id === entropySource); + } catch (error) { + failures.push({ + provider: provider.getName(), + error, + }); + continue; + } for (const account of owned) { try { From c34218a7d2737b7ca6aad04e5283d324a3e64c38 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Thu, 4 Jun 2026 11:32:42 -0400 Subject: [PATCH 21/21] fix: regenerate method action types --- .../MultichainAccountService-method-action-types.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/multichain-account-service/src/MultichainAccountService-method-action-types.ts b/packages/multichain-account-service/src/MultichainAccountService-method-action-types.ts index 2a416cb884..7d998dd761 100644 --- a/packages/multichain-account-service/src/MultichainAccountService-method-action-types.ts +++ b/packages/multichain-account-service/src/MultichainAccountService-method-action-types.ts @@ -82,12 +82,12 @@ export type MultichainAccountServiceCreateMultichainAccountWalletAction = { * * The deletion iterates providers (the source of truth for their own * account lists) and filters each provider's accounts to those matching - * the wallet's entropy source. Per-account deletions are best-effort: a - * single account failure does not abort cleanup of the remaining - * accounts. If one or more deletions fail, a single aggregated error is - * reported via `reportError` with all per-account failure details in its - * context. The wallet is always removed from the service's internal map - * at the end. + * the wallet's entropy source. Cleanup is best-effort end-to-end: neither + * a single account deletion failure nor a failure to enumerate a given + * provider's accounts aborts cleanup of the remaining providers. If one or + * more operations fail, a single aggregated error is reported via + * `reportError` with all per-failure details in its context. The wallet is + * always removed from the service's internal map at the end. * * @param entropySource - The entropy source of the multichain account wallet. */