Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
90f23ba
feat(snap-account-service): add migration logic + keyring v2 support
ccharly May 12, 2026
54e8dc0
feat(multichain-account-service): use withKeyringV2 for Snap account …
ccharly May 4, 2026
ed0c8dc
fix: fix failing migration
ccharly May 12, 2026
ef07834
fix: use deleteAccount instead of removeAccount
ccharly May 12, 2026
a14f395
chore: lint
ccharly May 12, 2026
99d1c69
refactor: cosmetic (migrated flag)
ccharly May 12, 2026
f1067d6
chore: lint
ccharly May 12, 2026
940a8cc
feat: use keyring v2 for handleKeyringSnapMessage
ccharly May 12, 2026
68869ff
refactor: use v2 keyrings for setSelectedAccounts
ccharly May 12, 2026
992b4ea
refactor: refactor withKeyringV2* calls
ccharly May 13, 2026
b1119d2
refactor: filters accounts per Snap Id for getSelectedAccounts
ccharly May 13, 2026
851ca8e
chore: move migration logs
ccharly May 13, 2026
94a8c4a
refactor!: remove getLegacySnapKeyring (not usable after running the …
ccharly May 13, 2026
262b890
chore: fix changelog
ccharly May 13, 2026
6d71351
chore: fix changelogs
ccharly May 13, 2026
9b0fce0
refactor: bump eth-snap-keyring + use isSnapKeyring
ccharly May 18, 2026
441ec23
chore: lint
ccharly May 18, 2026
3ab5f83
chore: changelog
ccharly May 18, 2026
f39f26b
fix: remove old action :getLegacySnapKeyring export
ccharly May 18, 2026
61b8446
fix: add missing export
ccharly May 18, 2026
68d7fdd
Merge branch 'main' into cc/feat/snap-account-service-keyring-v2-support
ccharly May 21, 2026
a4b03df
fix: watcher no longer waits for legacy Snap keyring
ccharly May 21, 2026
6d6b51e
chore: lint
ccharly May 21, 2026
8e8c4f6
fix: relax :handleKeyringSnapMessage for v2
ccharly May 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions eslint-suppressions.json
Original file line number Diff line number Diff line change
Expand Up @@ -2156,12 +2156,12 @@
},
"packages/snap-account-service/src/SnapPlatformWatcher.test.ts": {
"no-restricted-syntax": {
"count": 2
"count": 1
}
},
"packages/snap-account-service/src/SnapPlatformWatcher.ts": {
"no-restricted-syntax": {
"count": 2
"count": 1
}
},
"packages/subscription-controller/src/SubscriptionController.test.ts": {
Expand Down
4 changes: 4 additions & 0 deletions packages/accounts-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed

- Bump `@metamask/eth-snap-keyring` from `^22.0.1` to `^22.1.0` ([#8732](https://github.com/MetaMask/core/pull/8732))

## [38.1.1]

### Changed
Expand Down
2 changes: 1 addition & 1 deletion packages/accounts-controller/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@
"dependencies": {
"@ethereumjs/util": "^9.1.0",
"@metamask/base-controller": "^9.1.0",
"@metamask/eth-snap-keyring": "^22.0.1",
"@metamask/eth-snap-keyring": "^22.1.0",
"@metamask/keyring-api": "^23.1.0",
"@metamask/keyring-controller": "^25.5.0",
"@metamask/keyring-internal-api": "^11.0.1",
Expand Down
2 changes: 2 additions & 0 deletions packages/multichain-account-service/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- **BREAKING:** Replace `KeyringController:withKeyring` with `KeyringController:withKeyringV2` for the Snap account providers ([#8732](https://github.com/MetaMask/core/pull/8732))
- Bump `@metamask/snap-account-service` from `^0.1.0` to `^0.2.0` ([#8844](https://github.com/MetaMask/core/pull/8844))
- Bump `@metamask/eth-snap-keyring` from `^22.0.1` to `^22.1.0` ([#8732](https://github.com/MetaMask/core/pull/8732))

## [10.0.0]

Expand Down
2 changes: 1 addition & 1 deletion packages/multichain-account-service/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@
"@ethereumjs/util": "^9.1.0",
"@metamask/accounts-controller": "^38.1.1",
"@metamask/base-controller": "^9.1.0",
"@metamask/eth-snap-keyring": "^22.0.1",
"@metamask/eth-snap-keyring": "^22.1.0",
"@metamask/key-tree": "^10.1.1",
"@metamask/keyring-api": "^23.1.0",
"@metamask/keyring-controller": "^25.5.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import type {
} from '@metamask/keyring-api/v2';
import type {
KeyringMetadata,
KeyringSelector,
KeyringSelectorV2,
} from '@metamask/keyring-controller';
import type { InternalAccount } from '@metamask/keyring-internal-api';
Expand Down Expand Up @@ -159,40 +158,6 @@ export abstract class BaseBip44AccountProvider<
) as unknown as Account;
}

/**
* Run an operation against a V1 keyring selected by `selector`.
*
* Forwards to `KeyringController:withKeyring`. Use this for keyrings that
* have not yet migrated to the unified V2 `Keyring` interface (e.g. the
* snap keyring).
*
* @param selector - The selector identifying the keyring.
* @param operation - The operation to run with the selected keyring.
* @returns The result of the operation.
*/
protected async withKeyring<SelectedKeyring, CallbackResult = void>(
selector: KeyringSelector,
operation: ({
keyring,
metadata,
}: {
keyring: SelectedKeyring;
metadata: KeyringMetadata;
}) => Promise<CallbackResult>,
): Promise<CallbackResult> {
const result = await this.messenger.call(
'KeyringController:withKeyring',
selector,
({ keyring, metadata }) =>
operation({
keyring: keyring as SelectedKeyring,
metadata,
}),
);

return result as CallbackResult;
}

/**
* Run an operation against a V2 keyring selected by `selector`.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,7 @@
import { isBip44Account } from '@metamask/account-api';
import type { SnapKeyring } from '@metamask/eth-snap-keyring';
import { AccountCreationType, BtcAccountType } from '@metamask/keyring-api';
import type { KeyringMetadata } from '@metamask/keyring-controller';
import type {
EthKeyring,
InternalAccount,
} from '@metamask/keyring-internal-api';
import type { InternalAccount } from '@metamask/keyring-internal-api';
import { SnapControllerState } from '@metamask/snaps-controllers';
import deepmerge from 'deepmerge';

Expand Down Expand Up @@ -69,9 +65,9 @@ class MockBtcKeyring {
return Number(index);
}

createAccount: SnapKeyring['createAccount'] = jest
createAccount = jest
.fn()
.mockImplementation((_, { derivationPath, index, ...options }) => {
.mockImplementation(({ derivationPath, index, ...options }) => {
// Determine the group index to use - either from derivationPath parsing, explicit index, or fallback
let groupIndex: number;

Expand Down Expand Up @@ -110,34 +106,32 @@ class MockBtcKeyring {
return account;
});

createAccounts: SnapKeyring['createAccounts'] = jest
.fn()
.mockImplementation((_, options) => {
const groupIndices =
options.type === 'bip44:derive-index'
? [options.groupIndex]
: toGroupIndexRangeArray(options.range);

return groupIndices.map((groupIndex) => {
const found = this.accounts.find(
(account) =>
isBip44Account(account) &&
account.options.entropy.groupIndex === groupIndex,
);

if (found) {
return found; // Idempotent.
}

const account = MockAccountBuilder.from(MOCK_BTC_P2WPKH_ACCOUNT_1)
.withUuid()
.withAddressSuffix(`${groupIndex}`)
.withGroupIndex(groupIndex)
.get();
this.accounts.push(account);
return account;
});
createAccounts = jest.fn().mockImplementation((options) => {
const groupIndices =
options.type === 'bip44:derive-index'
? [options.groupIndex]
: toGroupIndexRangeArray(options.range);

return groupIndices.map((groupIndex) => {
const found = this.accounts.find(
(account) =>
isBip44Account(account) &&
account.options.entropy.groupIndex === groupIndex,
);

if (found) {
return found; // Idempotent.
}

const account = MockAccountBuilder.from(MOCK_BTC_P2WPKH_ACCOUNT_1)
.withUuid()
.withAddressSuffix(`${groupIndex}`)
.withGroupIndex(groupIndex)
.get();
this.accounts.push(account);
return account;
});
});
}

class MockBtcAccountProvider extends BtcAccountProvider {
Expand Down Expand Up @@ -212,12 +206,10 @@ function setup({
);

messenger.registerActionHandler(
'KeyringController:withKeyring',
'KeyringController:withKeyringV2',
async (_, operation) =>
operation({
// We type-cast here, since `withKeyring` defaults to `EthKeyring` and the
// Snap keyring doesn't really implement this interface (this is expected).
keyring: keyring as unknown as EthKeyring,
keyring,
metadata: keyring.metadata,
}),
);
Expand All @@ -243,8 +235,8 @@ function setup({
mocks: {
handleRequest: mockHandleRequest,
keyring: {
createAccount: keyring.createAccount as jest.Mock,
createAccounts: keyring.createAccounts as jest.Mock,
createAccount: keyring.createAccount,
createAccounts: keyring.createAccounts,
},
trace: mockTrace,
},
Expand Down Expand Up @@ -483,14 +475,11 @@ describe('BtcAccountProvider', () => {
});
expect(newAccounts).toHaveLength(1);
// Batch endpoint must be called, NOT the singular one.
expect(mocks.keyring.createAccounts).toHaveBeenCalledWith(
BtcAccountProvider.BTC_SNAP_ID,
{
type: AccountCreationType.Bip44DeriveIndex,
entropySource: MOCK_HD_KEYRING_1.metadata.id,
groupIndex: newGroupIndex,
},
);
expect(mocks.keyring.createAccounts).toHaveBeenCalledWith({
type: AccountCreationType.Bip44DeriveIndex,
entropySource: MOCK_HD_KEYRING_1.metadata.id,
groupIndex: newGroupIndex,
});
expect(mocks.keyring.createAccount).not.toHaveBeenCalled();
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type {
KeyringCapabilities,
} from '@metamask/keyring-api';
import type { EntropySourceId, KeyringAccount } from '@metamask/keyring-api';
import type { KeyringMetadata } from '@metamask/keyring-controller';
import type { InternalAccount } from '@metamask/keyring-internal-api';
import type { JsonRpcRequest, SnapId } from '@metamask/snaps-sdk';
import deepmerge from 'deepmerge';
Expand Down Expand Up @@ -153,10 +154,12 @@ const setup = ({
config: configOverride = {},
messenger = getRootMessenger(),
accounts = [],
keyring: keyringOverrides = {},
}: {
config?: DeepPartial<SnapAccountProviderConfig>;
messenger?: RootMessenger;
accounts?: InternalAccount[];
keyring?: { type?: string; snapId?: SnapId };
} = {}) => {
const mocks = {
AccountsController: {
Expand All @@ -165,6 +168,9 @@ const setup = ({
ErrorReportingService: {
captureException: jest.fn(),
},
KeyringController: {
withKeyringV2: jest.fn(),
},
SnapController: {
handleKeyringRequest: {
getAccount: jest.fn(),
Expand Down Expand Up @@ -223,18 +229,30 @@ const setup = ({
);

const keyring = {
type: keyringOverrides.type ?? 'snap',
snapId: keyringOverrides.snapId ?? TEST_SNAP_ID,
createAccount: jest.fn(),
createAccounts: jest.fn(),
removeAccount: jest.fn(),
deleteAccount: jest.fn().mockResolvedValue(undefined),
lookupByAddress: jest
.fn()
.mockImplementation((address: string) =>
accounts.map(asKeyringAccount).find((a) => a.address === address),
),
};
const metadata = { id: 'mock-keyring-id', name: '' } as KeyringMetadata;

mocks.KeyringController.withKeyringV2.mockImplementation(
async (selector, operation) => {
if (selector.filter && !selector.filter(keyring, metadata)) {
throw new Error('No keyring matches the selector');
}
return await operation({ keyring, metadata });
},
);
messenger.registerActionHandler(
'KeyringController:withKeyring',
jest
.fn()
.mockImplementation(
async (_ /* selector */, operation) => await operation({ keyring }),
),
'KeyringController:withKeyringV2',
mocks.KeyringController.withKeyringV2,
);

const serviceMessenger = getMultichainAccountServiceMessenger(messenger);
Expand Down Expand Up @@ -865,10 +883,8 @@ describe('SnapAccountProvider', () => {
mocks.SnapController.handleKeyringRequest.deleteAccount,
).toHaveBeenCalledWith(extraSnapAccount2.id);

// Should remove from keyring and recreate the missing account
expect(keyring.removeAccount).toHaveBeenCalledWith(
mockAccounts[1].address,
);
// Should delete the missing account from the keyring (by id) before recreating it.
expect(keyring.deleteAccount).toHaveBeenCalledWith(mockAccounts[1].id);
expect(createAccountsSpy).toHaveBeenCalledWith({
entropySource: mockAccounts[1].options.entropy.id,
groupIndex: mockAccounts[1].options.entropy.groupIndex,
Expand Down Expand Up @@ -949,6 +965,37 @@ describe('SnapAccountProvider', () => {
});
});

describe('withKeyringV2 selector', () => {
const mockAccounts = [
MockAccountBuilder.from(MOCK_HD_ACCOUNT_1)
.withUuid()
.withSnapId(TEST_SNAP_ID)
.get(),
].filter(isBip44Account);

it('rejects when the keyring type is not a Snap keyring', async () => {
const { provider } = setup({
accounts: mockAccounts,
keyring: { type: 'not-a-snap-keyring' },
});

await expect(provider.resyncAccounts(mockAccounts)).rejects.toThrow(
'No keyring matches the selector',
);
});

it('rejects when the Snap keyring is for a different Snap ID', async () => {
const { provider } = setup({
accounts: mockAccounts,
keyring: { snapId: 'npm:@metamask/other-snap' as SnapId },
});

await expect(provider.resyncAccounts(mockAccounts)).rejects.toThrow(
'No keyring matches the selector',
);
});
});

describe('ensureReady', () => {
it('delegates Snap platform readiness check to SnapAccountService:ensureReady', async () => {
const { provider, mocks } = setup();
Expand Down
Loading
Loading