Skip to content

Commit 2adb638

Browse files
committed
feat: use keyring v2 for handleKeyringSnapMessage
1 parent a71cdaa commit 2adb638

4 files changed

Lines changed: 218 additions & 17 deletions

File tree

packages/snap-account-service/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@
5858
"@metamask/eth-snap-keyring": "^22.0.1",
5959
"@metamask/keyring-api": "^23.1.0",
6060
"@metamask/keyring-controller": "^25.5.0",
61+
"@metamask/keyring-snap-sdk": "^9.0.1",
6162
"@metamask/messenger": "^1.2.0",
6263
"@metamask/snaps-controllers": "^19.0.0",
6364
"@metamask/snaps-sdk": "^11.0.0",

packages/snap-account-service/src/SnapAccountService.test.ts

Lines changed: 154 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,22 @@
11
import type { AccountGroupId } from '@metamask/account-api';
22
import { SNAP_KEYRING_TYPE } from '@metamask/eth-snap-keyring';
33
import type { SnapKeyring, SnapMessage } from '@metamask/eth-snap-keyring';
4+
import type { SnapKeyring as SnapKeyringV2 } from '@metamask/eth-snap-keyring/v2';
5+
import { KeyringEvent } from '@metamask/keyring-api';
46
import { KeyringType } from '@metamask/keyring-api/v2';
57
import {
8+
KeyringControllerError,
9+
KeyringControllerErrorMessage,
610
KeyringControllerState,
711
KeyringTypes,
812
} from '@metamask/keyring-controller';
913
import type {
1014
KeyringEntry,
15+
KeyringMetadata,
16+
KeyringSelectorV2,
1117
RestrictedController,
1218
} from '@metamask/keyring-controller';
19+
import { SnapManageAccountsMethod } from '@metamask/keyring-snap-sdk';
1320
import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger';
1421
import type {
1522
MockAnyNamespace,
@@ -64,6 +71,7 @@ type Mocks = {
6471
KeyringController: {
6572
getState: jest.MockedFunction<() => { keyrings: { type: string }[] }>;
6673
withController: jest.Mock;
74+
withKeyringV2: jest.Mock;
6775
};
6876
// eslint-disable-next-line @typescript-eslint/naming-convention
6977
AccountTreeController: {
@@ -105,6 +113,7 @@ function getMessenger(
105113
'SnapController:getRunnableSnaps',
106114
'KeyringController:getState',
107115
'KeyringController:withController',
116+
'KeyringController:withKeyringV2',
108117
'AccountTreeController:getAccountGroupObject',
109118
'AccountTreeController:getSelectedAccountGroup',
110119
],
@@ -317,6 +326,57 @@ function mockWithController(
317326
return { addNewKeyring };
318327
}
319328

329+
/**
330+
* Configures `mocks.KeyringController.withKeyringV2` so that the operation
331+
* receives a Snap keyring v2 matching the given selector, or throws
332+
* `KeyringNotFound` when none matches.
333+
*
334+
* @param mocks - The mocks object from {@link setup}.
335+
* @param keyrings - The available v2 Snap keyrings, keyed by snap ID.
336+
*/
337+
function mockWithKeyringV2(
338+
mocks: Mocks,
339+
keyrings: Record<string, Pick<SnapKeyringV2, 'handleKeyringSnapMessage'>>,
340+
): void {
341+
mocks.KeyringController.withKeyringV2.mockImplementation(
342+
async (
343+
selector: KeyringSelectorV2,
344+
operation: (args: {
345+
keyring: SnapKeyringV2;
346+
metadata: KeyringMetadata;
347+
}) => Promise<unknown>,
348+
) => {
349+
const entry = Object.entries(keyrings).find(([snapId, kr]) =>
350+
// The selector's filter expects a v2 keyring object; we synthesise
351+
// a minimal shape (`type` + `snapId`) so the production filter
352+
// function can identify it.
353+
selector.filter?.(
354+
{
355+
type: KeyringType.Snap,
356+
snapId,
357+
...kr,
358+
} as unknown,
359+
{ id: `id-${snapId}`, name: 'snap' } as KeyringMetadata,
360+
),
361+
);
362+
if (!entry) {
363+
throw new KeyringControllerError(
364+
KeyringControllerErrorMessage.KeyringNotFound,
365+
);
366+
}
367+
const [snapId, kr] = entry;
368+
return operation({
369+
keyring: {
370+
type: KeyringType.Snap,
371+
snapId,
372+
...kr,
373+
} as unknown as SnapKeyringV2,
374+
metadata: { id: `id-${snapId}`, name: 'snap' } as KeyringMetadata,
375+
});
376+
},
377+
);
378+
}
379+
320380
/**
321381
* Configures `mocks.KeyringController.withController` to expose a single
322382
* legacy Snap keyring with the provided mocked methods.
@@ -400,6 +460,7 @@ function setup({
400460
KeyringController: {
401461
getState: jest.fn().mockReturnValue({ keyrings }),
402462
withController: jest.fn(),
463+
withKeyringV2: jest.fn(),
403464
},
404465
AccountTreeController: {
405466
getAccountGroupObject: jest.fn().mockReturnValue(undefined),
@@ -423,6 +484,10 @@ function setup({
423484
'KeyringController:withController',
424485
mocks.KeyringController.withController,
425486
);
487+
rootMessenger.registerActionHandler(
488+
'KeyringController:withKeyringV2',
489+
mocks.KeyringController.withKeyringV2,
490+
);
426491
rootMessenger.registerActionHandler(
427492
'AccountTreeController:getAccountGroupObject',
428493
mocks.AccountTreeController.getAccountGroupObject,
@@ -833,44 +898,122 @@ describe('SnapAccountService', () => {
833898

834899
describe('handleKeyringSnapMessage', () => {
835900
const MOCK_MESSAGE = {
836-
method: 'keyring_listAccounts',
901+
method: KeyringEvent.AccountUpdated,
902+
params: {},
903+
} as unknown as SnapMessage;
904+
const MOCK_ACCOUNT_CREATED_MESSAGE = {
905+
method: KeyringEvent.AccountCreated,
837906
params: {},
838907
} as unknown as SnapMessage;
908+
const MOCK_GROUP_ID = 'keyring:01JABC/group-1' as AccountGroupId;
909+
const MOCK_ACCOUNTS = [
910+
'00000000-0000-0000-0000-000000000001',
911+
'00000000-0000-0000-0000-000000000002',
912+
];
839913

840-
it('forwards the call to the legacy Snap keyring and returns its result', async () => {
914+
it('forwards the message to the matching v2 Snap keyring and returns its result', async () => {
841915
const { service, mocks } = setup();
842916
const handleKeyringSnapMessage = jest
843917
.fn()
844918
.mockResolvedValue({ ok: true });
845-
mockLegacySnapKeyring(mocks, { handleKeyringSnapMessage });
919+
mockWithKeyringV2(mocks, {
920+
[MOCK_SNAP_ID]: { handleKeyringSnapMessage },
921+
});
846922

847923
const result = await service.handleKeyringSnapMessage(
848924
MOCK_SNAP_ID,
849925
MOCK_MESSAGE,
850926
);
851927

852-
expect(handleKeyringSnapMessage).toHaveBeenCalledWith(
853-
MOCK_SNAP_ID,
854-
MOCK_MESSAGE,
855-
);
928+
expect(handleKeyringSnapMessage).toHaveBeenCalledWith(MOCK_MESSAGE);
856929
expect(result).toStrictEqual({ ok: true });
857930
});
858931

859-
it('propagates errors thrown by the Snap keyring', async () => {
932+
it('short-circuits the GetSelectedAccounts method by returning the selected account group accounts', async () => {
933+
const { service, mocks } = setup();
934+
mocks.AccountTreeController.getSelectedAccountGroup.mockReturnValue(
935+
MOCK_GROUP_ID,
936+
);
937+
mocks.AccountTreeController.getAccountGroupObject.mockReturnValue(
938+
buildGroup(MOCK_GROUP_ID, MOCK_ACCOUNTS),
939+
);
940+
941+
const result = await service.handleKeyringSnapMessage(MOCK_SNAP_ID, {
942+
method: SnapManageAccountsMethod.GetSelectedAccounts,
943+
params: {},
944+
} as unknown as SnapMessage);
945+
946+
expect(result).toStrictEqual(MOCK_ACCOUNTS);
947+
expect(mocks.KeyringController.withKeyringV2).not.toHaveBeenCalled();
948+
});
949+
950+
it('returns an empty array for GetSelectedAccounts when no account group is selected', async () => {
951+
const { service } = setup();
952+
953+
const result = await service.handleKeyringSnapMessage(MOCK_SNAP_ID, {
954+
method: SnapManageAccountsMethod.GetSelectedAccounts,
955+
params: {},
956+
} as unknown as SnapMessage);
957+
958+
expect(result).toStrictEqual([]);
959+
});
960+
961+
it('throws a dedicated error when no v2 Snap keyring exists for the given Snap', async () => {
962+
const { service, mocks } = setup();
963+
mockWithKeyringV2(mocks, {});
964+
965+
await expect(
966+
service.handleKeyringSnapMessage(MOCK_SNAP_ID, MOCK_MESSAGE),
967+
).rejects.toThrow(
968+
`Cannot delegate keyring Snap message, keyring does not exist yet for Snap "${MOCK_SNAP_ID}".`,
969+
);
970+
});
971+
972+
it('propagates non-KeyringNotFound errors thrown by the Snap keyring', async () => {
860973
const { service, mocks } = setup();
861974
const error = new Error('snap boom');
862975
const handleKeyringSnapMessage = jest.fn().mockRejectedValue(error);
863-
mockLegacySnapKeyring(mocks, { handleKeyringSnapMessage });
976+
mockWithKeyringV2(mocks, {
977+
[MOCK_SNAP_ID]: { handleKeyringSnapMessage },
978+
});
864979

865980
await expect(
866981
service.handleKeyringSnapMessage(MOCK_SNAP_ID, MOCK_MESSAGE),
867982
).rejects.toThrow(error);
868983
});
869984

985+
it('ensures the v2 keyring exists before forwarding an AccountCreated event', async () => {
986+
const { service, mocks } = setup();
987+
// `#ensureKeyringIsReady` uses `withController` — start with no keyring
988+
// so it must create one.
989+
const { addNewKeyring } = mockWithController(mocks, []);
990+
const handleKeyringSnapMessage = jest.fn().mockResolvedValue(null);
991+
// `withController` mock takes precedence over `withKeyringV2`; configure
992+
// `withKeyringV2` separately for the forwarding step.
993+
mockWithKeyringV2(mocks, {
994+
[MOCK_SNAP_ID]: { handleKeyringSnapMessage },
995+
});
996+
997+
await service.handleKeyringSnapMessage(
998+
MOCK_SNAP_ID,
999+
MOCK_ACCOUNT_CREATED_MESSAGE,
1000+
);
1001+
1002+
expect(addNewKeyring).toHaveBeenCalledWith(KeyringType.Snap, {
1003+
snapId: MOCK_SNAP_ID,
1004+
accounts: {},
1005+
});
1006+
expect(handleKeyringSnapMessage).toHaveBeenCalledWith(
1007+
MOCK_ACCOUNT_CREATED_MESSAGE,
1008+
);
1009+
});
1010+
8701011
it('is exposed as a messenger action', async () => {
8711012
const { service, mocks, messenger } = setup();
8721013
const handleKeyringSnapMessage = jest.fn().mockResolvedValue('pong');
873-
mockLegacySnapKeyring(mocks, { handleKeyringSnapMessage });
1014+
mockWithKeyringV2(mocks, {
1015+
[MOCK_SNAP_ID]: { handleKeyringSnapMessage },
1016+
});
8741017

8751018
// Reference `service` so it isn't flagged as unused; constructing it
8761019
// registers the messenger action under test.
@@ -882,10 +1025,7 @@ describe('SnapAccountService', () => {
8821025
MOCK_MESSAGE,
8831026
);
8841027

885-
expect(handleKeyringSnapMessage).toHaveBeenCalledWith(
886-
MOCK_SNAP_ID,
887-
MOCK_MESSAGE,
888-
);
1028+
expect(handleKeyringSnapMessage).toHaveBeenCalledWith(MOCK_MESSAGE);
8891029
expect(result).toBe('pong');
8901030
});
8911031
});

packages/snap-account-service/src/SnapAccountService.ts

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,21 @@ import type {
44
SnapMessage,
55
} from '@metamask/eth-snap-keyring';
66
import { SnapKeyring, SnapKeyringState } from '@metamask/eth-snap-keyring/v2';
7+
import { KeyringEvent } from '@metamask/keyring-api';
78
import { Keyring, KeyringType } from '@metamask/keyring-api/v2';
89
import type {
910
KeyringControllerGetStateAction,
1011
KeyringControllerStateChangeEvent,
1112
KeyringControllerUnlockEvent,
1213
KeyringControllerWithControllerAction,
14+
KeyringControllerWithKeyringV2Action,
1315
KeyringEntry,
1416
} from '@metamask/keyring-controller';
15-
import { KeyringTypes } from '@metamask/keyring-controller';
17+
import {
18+
isKeyringNotFoundError,
19+
KeyringTypes,
20+
} from '@metamask/keyring-controller';
21+
import { SnapManageAccountsMethod } from '@metamask/keyring-snap-sdk';
1622
import type { AccountId, BaseKeyring } from '@metamask/keyring-utils';
1723
import type { Messenger } from '@metamask/messenger';
1824
import type {
@@ -88,6 +94,7 @@ type AllowedActions =
8894
| SnapControllerGetRunnableSnapsAction
8995
| KeyringControllerGetStateAction
9096
| KeyringControllerWithControllerAction
97+
| KeyringControllerWithKeyringV2Action
9198
| AccountTreeControllerGetAccountGroupObjectAction
9299
| AccountTreeControllerGetSelectedAccountGroupAction;
93100

@@ -515,8 +522,60 @@ export class SnapAccountService {
515522
snapId: SnapId,
516523
message: SnapMessage,
517524
): Promise<Json> {
518-
const snapKeyring = await this.getLegacySnapKeyring();
519-
return snapKeyring.handleKeyringSnapMessage(snapId, message);
525+
// Handle specific methods first.
526+
if (message.method === SnapManageAccountsMethod.GetSelectedAccounts) {
527+
return (
528+
this.#getAccountGroup(this.#getSelectedAccountGroupId())?.accounts ?? []
529+
);
530+
}
531+
532+
const event = message.method as KeyringEvent; // We assume the Snap platform always sends a valid `KeyringEvent` here.
533+
log(
534+
`Forwarding message "${event}" from Snap "${snapId}" to its keyring...`,
535+
);
536+
537+
const isSnapKeyringForThisSnap = (
538+
keyring: Keyring,
539+
): keyring is SnapKeyring =>
540+
isSnapKeyring(keyring) && keyring.snapId === snapId;
541+
542+
// We can create a new keyring if the message is an AccountCreated event.
543+
const isAccountCreatedMessage = event === KeyringEvent.AccountCreated;
544+
545+
// Create the Snap keyring if it doesn't exist yet (in an atomic way). We cannot assume
546+
// the keyring exists (e.g for the MMI Snap).
547+
// NOTE: We only auto-create it for v1 account creation flows.
548+
if (isAccountCreatedMessage) {
549+
await this.#ensureKeyringIsReady(snapId);
550+
}
551+
552+
// This part of the flow relies on v1 flows, but v2 keyrings are compatible with those messages
553+
// too.
554+
try {
555+
const result = await this.#messenger.call(
556+
'KeyringController:withKeyringV2',
557+
{ filter: (keyring) => isSnapKeyringForThisSnap(keyring) },
558+
async ({ keyring }) => {
559+
const snapKeyring = keyring as SnapKeyring; // Forced to cast here as generic does not work when using the messenger.
560+
561+
return await snapKeyring.handleKeyringSnapMessage(message);
562+
},
563+
);
564+
565+
return result as Json;
566+
} catch (error) {
567+
if (isKeyringNotFoundError(error)) {
568+
log(
569+
`No Snap keyring found for Snap "${snapId}". Cannot handle message with method "${event}".`,
570+
);
571+
572+
throw new Error(
573+
`Cannot delegate keyring Snap message, keyring does not exist yet for Snap "${snapId}".`,
574+
);
575+
}
576+
577+
throw error;
578+
}
520579
}
521580

522581
/**

yarn.lock

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5459,6 +5459,7 @@ __metadata:
54595459
"@metamask/eth-snap-keyring": "npm:^22.0.1"
54605460
"@metamask/keyring-api": "npm:^23.1.0"
54615461
"@metamask/keyring-controller": "npm:^25.5.0"
5462+
"@metamask/keyring-snap-sdk": "npm:^9.0.1"
54625463
"@metamask/keyring-utils": "npm:^3.2.1"
54635464
"@metamask/messenger": "npm:^1.2.0"
54645465
"@metamask/snaps-controllers": "npm:^19.0.0"

0 commit comments

Comments
 (0)