Skip to content

Commit b3013c4

Browse files
committed
feat(snap-account-service): add handleKeyringSnapMessage
1 parent 41ed5aa commit b3013c4

4 files changed

Lines changed: 233 additions & 5 deletions

File tree

packages/snap-account-service/src/SnapAccountService-method-action-types.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,11 @@ export type SnapAccountServiceGetSnapsAction = {
2020
/**
2121
* Ensures everything is ready to use Snap accounts for the given Snap.
2222
* 1. Validates that `snapId` is a tracked account-management Snap.
23-
* 2. Waits for the Snap platform to be fully started.
23+
* 2. Runs the legacy -> v2 Snap keyring migration (cached — no-op if
24+
* already done).
25+
* 3. Atomically creates the v2 keyring for this Snap if it doesn't exist
26+
* yet.
27+
* 4. Waits for the Snap platform to be fully started.
2428
*
2529
* Safe to call concurrently — each step is idempotent or mutex-protected.
2630
*
@@ -32,9 +36,26 @@ export type SnapAccountServiceEnsureReadyAction = {
3236
handler: SnapAccountService['ensureReady'];
3337
};
3438

39+
/**
40+
* Handle a message from a Snap.
41+
*
42+
* Only `AccountCreated` triggers lazy keyring creation, since that is the
43+
* single entry point for the v1 event-driven flow. All other events from
44+
* unknown Snaps throw an error.
45+
*
46+
* @param snapId - ID of the Snap.
47+
* @param message - Message sent by the Snap.
48+
* @returns The execution result.
49+
*/
50+
export type SnapAccountServiceHandleKeyringSnapMessageAction = {
51+
type: `SnapAccountService:handleKeyringSnapMessage`;
52+
handler: SnapAccountService['handleKeyringSnapMessage'];
53+
};
54+
3555
/**
3656
* Union of all SnapAccountService action types.
3757
*/
3858
export type SnapAccountServiceMethodActions =
3959
| SnapAccountServiceGetSnapsAction
40-
| SnapAccountServiceEnsureReadyAction;
60+
| SnapAccountServiceEnsureReadyAction
61+
| SnapAccountServiceHandleKeyringSnapMessageAction;

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

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,14 @@ type Mocks = {
4343
withController: jest.MockedFunction<
4444
(callback: (controller: any) => Promise<unknown>) => Promise<unknown>
4545
>;
46+
withKeyringV2Unsafe: jest.MockedFunction<
47+
(
48+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
49+
selector: any,
50+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
51+
operation: (selected: { keyring: any }) => Promise<unknown>,
52+
) => Promise<unknown>
53+
>;
4654
};
4755
};
4856

@@ -76,6 +84,7 @@ function getMessenger(
7684
'SnapController:getRunnableSnaps',
7785
'KeyringController:getState',
7886
'KeyringController:withController',
87+
'KeyringController:withKeyringV2Unsafe',
7988
],
8089
events: [
8190
'SnapController:stateChange',
@@ -253,6 +262,7 @@ function setup({
253262
removeKeyring: jest.fn().mockResolvedValue(undefined),
254263
}),
255264
),
265+
withKeyringV2Unsafe: jest.fn(),
256266
},
257267
};
258268

@@ -273,6 +283,11 @@ function setup({
273283
// eslint-disable-next-line @typescript-eslint/no-explicit-any
274284
mocks.KeyringController.withController as any,
275285
);
286+
rootMessenger.registerActionHandler(
287+
'KeyringController:withKeyringV2Unsafe',
288+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
289+
mocks.KeyringController.withKeyringV2Unsafe as any,
290+
);
276291

277292
const service = new SnapAccountService({ messenger, config });
278293

@@ -690,4 +705,101 @@ describe('SnapAccountService', () => {
690705
expect(resolved).toBe(true);
691706
});
692707
});
708+
709+
describe('handleKeyringSnapMessage', () => {
710+
it('returns an empty array for `getSelectedAccounts`', async () => {
711+
const { service, mocks } = setup();
712+
713+
const result = await service.handleKeyringSnapMessage(MOCK_SNAP_ID, {
714+
method: 'getSelectedAccounts',
715+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
716+
} as any);
717+
718+
expect(result).toStrictEqual([]);
719+
expect(mocks.KeyringController.withController).not.toHaveBeenCalled();
720+
expect(
721+
mocks.KeyringController.withKeyringV2Unsafe,
722+
).not.toHaveBeenCalled();
723+
});
724+
725+
it('throws for non-AccountCreated events', async () => {
726+
const { service } = setup();
727+
728+
await expect(
729+
service.handleKeyringSnapMessage(MOCK_SNAP_ID, {
730+
method: 'accountUpdated',
731+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
732+
} as any),
733+
).rejects.toThrow(
734+
'Cannot delegate keyring Snap message, keyring does not exist yet.',
735+
);
736+
});
737+
738+
it('auto-creates a Snap keyring on AccountCreated when one does not exist, then delegates', async () => {
739+
const addNewKeyring = jest.fn().mockResolvedValue(undefined);
740+
const removeKeyring = jest.fn().mockResolvedValue(undefined);
741+
const handleSnapMessage = jest.fn().mockResolvedValue('ok');
742+
const { service, mocks } = setup();
743+
mocks.KeyringController.withController.mockImplementation(
744+
async (callback) =>
745+
callback({ keyrings: [], addNewKeyring, removeKeyring }),
746+
);
747+
mocks.KeyringController.withKeyringV2Unsafe.mockImplementation(
748+
async (_selector, operation) =>
749+
operation({
750+
keyring: { handleKeyringSnapMessage: handleSnapMessage },
751+
}),
752+
);
753+
754+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
755+
const message = { method: 'notify:accountCreated' } as any;
756+
const result = await service.handleKeyringSnapMessage(
757+
MOCK_SNAP_ID,
758+
message,
759+
);
760+
761+
expect(addNewKeyring).toHaveBeenCalledWith(KeyringType.Snap, {
762+
snapId: MOCK_SNAP_ID,
763+
accounts: {},
764+
});
765+
expect(handleSnapMessage).toHaveBeenCalledWith(message);
766+
expect(result).toBe('ok');
767+
});
768+
769+
it('does not re-create the Snap keyring on AccountCreated when one already exists', async () => {
770+
const addNewKeyring = jest.fn().mockResolvedValue(undefined);
771+
const removeKeyring = jest.fn().mockResolvedValue(undefined);
772+
const handleSnapMessage = jest.fn().mockResolvedValue('ok');
773+
const { service, mocks } = setup();
774+
mocks.KeyringController.withController.mockImplementation(
775+
async (callback) =>
776+
callback({
777+
keyrings: [
778+
{
779+
keyringV2: {
780+
type: KeyringType.Snap,
781+
snapId: MOCK_SNAP_ID,
782+
},
783+
},
784+
],
785+
addNewKeyring,
786+
removeKeyring,
787+
}),
788+
);
789+
mocks.KeyringController.withKeyringV2Unsafe.mockImplementation(
790+
async (_selector, operation) =>
791+
operation({
792+
keyring: { handleKeyringSnapMessage: handleSnapMessage },
793+
}),
794+
);
795+
796+
await service.handleKeyringSnapMessage(MOCK_SNAP_ID, {
797+
method: 'notify:accountCreated',
798+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
799+
} as any);
800+
801+
expect(addNewKeyring).not.toHaveBeenCalled();
802+
expect(handleSnapMessage).toHaveBeenCalled();
803+
});
804+
});
693805
});

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

Lines changed: 97 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,15 @@ import type {
66
SnapKeyring,
77
SnapKeyringState,
88
} from '@metamask/eth-snap-keyring/v2';
9+
import type { SnapMessage } from '@metamask/eth-snap-keyring';
10+
import { KeyringEvent } from '@metamask/keyring-api';
911
import type { Keyring } from '@metamask/keyring-api/v2';
1012
import { KeyringType } from '@metamask/keyring-api/v2';
1113
import type {
1214
KeyringControllerGetStateAction,
1315
KeyringControllerStateChangeEvent,
1416
KeyringControllerWithControllerAction,
17+
KeyringControllerWithKeyringV2UnsafeAction,
1518
} from '@metamask/keyring-controller';
1619
import type { Messenger } from '@metamask/messenger';
1720
import type {
@@ -24,13 +27,15 @@ import type {
2427
SnapControllerSnapUninstalledEvent,
2528
SnapControllerStateChangeEvent,
2629
} from '@metamask/snaps-controllers';
30+
import type { Json } from '@metamask/snaps-sdk';
2731
import { SnapId } from '@metamask/snaps-sdk';
2832
import type { TruncatedSnap } from '@metamask/snaps-utils';
2933

3034
import { projectLogger as log } from './logger';
3135
import type {
3236
SnapAccountServiceEnsureReadyAction,
3337
SnapAccountServiceGetSnapsAction,
38+
SnapAccountServiceHandleKeyringSnapMessageAction,
3439
} from './SnapAccountService-method-action-types';
3540
import { SnapPlatformWatcher } from './SnapPlatformWatcher';
3641
import type { SnapPlatformWatcherConfig } from './SnapPlatformWatcher';
@@ -67,14 +72,19 @@ export const serviceName = 'SnapAccountService';
6772
* All of the methods within {@link SnapAccountService} that are exposed via
6873
* the messenger.
6974
*/
70-
const MESSENGER_EXPOSED_METHODS = ['ensureReady', 'getSnaps'] as const;
75+
const MESSENGER_EXPOSED_METHODS = [
76+
'ensureReady',
77+
'getSnaps',
78+
'handleKeyringSnapMessage',
79+
] as const;
7180

7281
/**
7382
* Actions that {@link SnapAccountService} exposes to other consumers.
7483
*/
7584
export type SnapAccountServiceActions =
7685
| SnapAccountServiceEnsureReadyAction
77-
| SnapAccountServiceGetSnapsAction;
86+
| SnapAccountServiceGetSnapsAction
87+
| SnapAccountServiceHandleKeyringSnapMessageAction;
7888

7989
/**
8090
* Actions from other messengers that {@link SnapAccountService} calls.
@@ -83,7 +93,8 @@ type AllowedActions =
8393
| SnapControllerGetStateAction
8494
| SnapControllerGetRunnableSnapsAction
8595
| KeyringControllerGetStateAction
86-
| KeyringControllerWithControllerAction;
96+
| KeyringControllerWithControllerAction
97+
| KeyringControllerWithKeyringV2UnsafeAction;
8798

8899
/**
89100
* Events that {@link SnapAccountService} exposes to other consumers.
@@ -352,6 +363,89 @@ export class SnapAccountService {
352363
await this.#watcher.ensureCanUseSnapPlatform();
353364
}
354365

366+
/**
367+
* Handle a message from a Snap.
368+
*
369+
* Only `AccountCreated` triggers lazy keyring creation, since that is the
370+
* single entry point for the v1 event-driven flow. All other events from
371+
* unknown Snaps throw an error.
372+
*
373+
* @param snapId - ID of the Snap.
374+
* @param message - Message sent by the Snap.
375+
* @returns The execution result.
376+
*/
377+
async handleKeyringSnapMessage(
378+
snapId: SnapId,
379+
message: SnapMessage,
380+
): Promise<Json> {
381+
// We assume the Snap platform always sends a valid `KeyringEvent` here.
382+
const event = message.method as KeyringEvent;
383+
384+
if (message.method === 'getSelectedAccounts') {
385+
return [];
386+
}
387+
388+
const isSnapKeyringForThisSnap = (
389+
keyring: Keyring,
390+
): keyring is SnapKeyring =>
391+
isSnapKeyring(keyring) && keyring.snapId === snapId;
392+
393+
// We can create a new keyring if the message is an AccountCreated event.
394+
const isAccountCreatedMessage = event === KeyringEvent.AccountCreated;
395+
396+
// Create the Snap keyring if it doesn't exist yet (in an atomic way). We
397+
// cannot assume the keyring exists (e.g for the MMI Snap).
398+
// NOTE: We only auto-create it for v1 account creation flows.
399+
if (isAccountCreatedMessage) {
400+
await this.#messenger.call(
401+
'KeyringController:withController',
402+
async (controller) => {
403+
const hasSnapKeyring = controller.keyrings.some(
404+
({ keyringV2 }) => keyringV2 && isSnapKeyringForThisSnap(keyringV2),
405+
);
406+
407+
if (!hasSnapKeyring) {
408+
log(`Auto-creating Snap keyring for "${snapId}"...`);
409+
410+
await controller.addNewKeyring(KeyringType.Snap, {
411+
snapId,
412+
accounts: {},
413+
});
414+
415+
log(`Snap keyring for "${snapId}" is ready!`);
416+
}
417+
},
418+
);
419+
} else {
420+
log(
421+
`No Snap keyring found for snap "${snapId}". Cannot handle message with method "${event}".`,
422+
);
423+
424+
throw new Error(
425+
'Cannot delegate keyring Snap message, keyring does not exist yet.',
426+
);
427+
}
428+
429+
// This part of the flow relies on v1 flows, so we have to go with
430+
// "unsafe" to avoid deadlocks. The keyring persistence will be done in a
431+
// later stage of `handleKeyringSnapMessage` after the message is handled,
432+
// so we don't have to worry about that here.
433+
const result = await this.#messenger.call(
434+
'KeyringController:withKeyringV2Unsafe',
435+
{ filter: (keyring) => isSnapKeyringForThisSnap(keyring) },
436+
async ({ keyring }) => {
437+
const snapKeyring = keyring as SnapKeyring;
438+
return snapKeyring.handleKeyringSnapMessage(message);
439+
},
440+
);
441+
442+
log(
443+
`Snap keyring for "${snapId}" handled keyring message "${event}" successfully!`,
444+
);
445+
446+
return result as Json;
447+
}
448+
355449
/**
356450
* Handles a Snap being added (installed or enabled). If the Snap is an
357451
* account-management Snap, adds it to the internal set of tracked Snaps.

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export type {
99
export type {
1010
SnapAccountServiceEnsureReadyAction,
1111
SnapAccountServiceGetSnapsAction,
12+
SnapAccountServiceHandleKeyringSnapMessageAction,
1213
} from './SnapAccountService-method-action-types';
1314
export { SnapPlatformWatcher } from './SnapPlatformWatcher';
1415
export type { SnapPlatformWatcherConfig } from './SnapPlatformWatcher';

0 commit comments

Comments
 (0)