Skip to content

Commit f77569d

Browse files
committed
feat(snap-account-service): add migration logic + keyring v2 support
1 parent f809895 commit f77569d

6 files changed

Lines changed: 330 additions & 8 deletions

File tree

packages/snap-account-service/CHANGELOG.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Added
1111

1212
- Add `SnapAccountService` ([#8414](https://github.com/MetaMask/core/pull/8414))
13-
- Add `SnapPlatformWatcher` and `SnapAccountService.ensureReady` ([#8715](https://github.com/MetaMask/core/pull/8715)), ([#8725](https://github.com/MetaMask/core/pull/8725))
13+
- Add `SnapPlatformWatcher` and `SnapAccountService.ensureReady` ([#8715](https://github.com/MetaMask/core/pull/8715)), ([#8725](https://github.com/MetaMask/core/pull/8725)), ([#8732](https://github.com/MetaMask/core/pull/8732))
1414
- Waits for the Snap platform to be ready and for a Snap keyring to appear in `KeyringController` state before allowing Snap account operations.
1515
- Callers must ensure `init()` has run and the Snap is currently installed, enabled, non-blocked, and declares `endowment:keyring`.
1616
- `SnapAccountService.ensureReady` now awaits the watcher, so it only resolves once both conditions hold (or rejects if the Snap keyring does not appear within the configured timeout).
1717
- `SnapAccountService.ensureReady` now throws `Unknown snap: "<id>"` when called with a Snap ID that isn't tracked as an account-management Snap.
18+
- `SnapAccountService.ensureReady` now automatically creates the Snap keyring (v2) for a given Snap ID if it was not available.
1819
- Add `config` option to `SnapAccountService` constructor with a `snapPlatformWatcher` field exposing `ensureOnboardingComplete` and `snapKeyringWaitTimeoutMs` ([#8715](https://github.com/MetaMask/core/pull/8715))
1920
- Export `SnapAccountServiceConfig` and `SnapPlatformWatcherConfig` types.
2021
- Add `@metamask/keyring-controller` dependency ([#8715](https://github.com/MetaMask/core/pull/8715))
@@ -32,6 +33,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3233
- This logic used to live on the clients.
3334
- The service messenger now requires the `KeyringController:unlock`, `AccountTreeController:selectedAccountGroupChange`, `AccountTreeController:accountGroup{Created,Updated,Removed}` events.
3435
- The service messenger now requires the `AccountTreeController:getSelectedAccountGroup` and `AccountTreeController:getAccountGroupObject` actions.
36+
- Add `migrate` ([#8732](https://github.com/MetaMask/core/pull/8732))
37+
- The migration is guaranteed to be run when using `ensureReady`.
38+
- It is conccurent-free and can safely be called by multiple execution flows.
39+
- Once the migration has ran, the legacy Snap keyring will be emptied, thus, consumers are expected to use the new per-Snap keyring (v2) instances instead.
3540

3641
### Changed
3742

packages/snap-account-service/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
"@metamask/account-api": "^1.0.4",
5757
"@metamask/account-tree-controller": "^7.3.0",
5858
"@metamask/eth-snap-keyring": "^22.0.1",
59+
"@metamask/keyring-api": "^23.1.0",
5960
"@metamask/keyring-controller": "^25.5.0",
6061
"@metamask/messenger": "^1.2.0",
6162
"@metamask/snaps-controllers": "^19.0.0",

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

Lines changed: 18 additions & 1 deletion
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,6 +36,18 @@ export type SnapAccountServiceEnsureReadyAction = {
3236
handler: SnapAccountService['ensureReady'];
3337
};
3438

39+
/**
40+
* Migrate the legacy Snap keyring to the new (per-snap) Snap keyring v2.
41+
* Safe to call concurrently — the migration runs only once; all callers
42+
* await the same promise.
43+
*
44+
* @returns A promise that resolves when the migration is complete.
45+
*/
46+
export type SnapAccountServiceMigrateAction = {
47+
type: `SnapAccountService:migrate`;
48+
handler: SnapAccountService['migrate'];
49+
};
50+
3551
/**
3652
* Atomically gets-or-creates the legacy (v1) Snap keyring — the keyring
3753
* associated with {@link KeyringTypes.snap}.
@@ -61,5 +77,6 @@ export type SnapAccountServiceHandleKeyringSnapMessageAction = {
6177
export type SnapAccountServiceMethodActions =
6278
| SnapAccountServiceGetSnapsAction
6379
| SnapAccountServiceEnsureReadyAction
80+
| SnapAccountServiceMigrateAction
6481
| SnapAccountServiceGetLegacySnapKeyringAction
6582
| SnapAccountServiceHandleKeyringSnapMessageAction;

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

Lines changed: 161 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import type { AccountGroupId } from '@metamask/account-api';
2+
import { SNAP_KEYRING_TYPE } from '@metamask/eth-snap-keyring';
23
import type { SnapKeyring, SnapMessage } from '@metamask/eth-snap-keyring';
4+
import { KeyringType } from '@metamask/keyring-api/v2';
35
import {
46
KeyringControllerState,
57
KeyringTypes,
@@ -436,6 +438,7 @@ function setup({
436438
}
437439

438440
const MOCK_SNAP_ID = 'npm:@metamask/mock-snap' as SnapId;
441+
const MOCK_OTHER_SNAP_ID = 'npm:@metamask/other-snap' as SnapId;
439442

440443
describe('SnapAccountService', () => {
441444
describe('init', () => {
@@ -458,6 +461,93 @@ describe('SnapAccountService', () => {
458461
});
459462
});
460463

464+
describe('migrate', () => {
465+
it('runs the migration only once when called concurrently', async () => {
466+
const { service, mocks } = setup();
467+
mocks.KeyringController.withController.mockResolvedValue(undefined);
468+
469+
await Promise.all([service.migrate(), service.migrate()]);
470+
471+
expect(mocks.KeyringController.withController).toHaveBeenCalledTimes(1);
472+
});
473+
474+
it('is a no-op when no legacy Snap keyring is present', async () => {
475+
const addNewKeyring = jest.fn().mockResolvedValue(undefined);
476+
const removeKeyring = jest.fn().mockResolvedValue(undefined);
477+
const { service, mocks } = setup();
478+
mocks.KeyringController.withController.mockImplementation(
479+
async (operation) =>
480+
operation({ keyrings: [], addNewKeyring, removeKeyring }),
481+
);
482+
483+
await service.migrate();
484+
485+
expect(addNewKeyring).not.toHaveBeenCalled();
486+
expect(removeKeyring).not.toHaveBeenCalled();
487+
});
488+
489+
it('migrates accounts from the legacy Snap keyring to per-Snap v2 keyrings and removes the legacy entry', async () => {
490+
const addNewKeyring = jest.fn().mockResolvedValue(undefined);
491+
const removeKeyring = jest.fn().mockResolvedValue(undefined);
492+
const legacyKeyringId = 'legacy-keyring-id';
493+
const account1 = {
494+
id: 'account-1',
495+
address: '0x1',
496+
metadata: { snap: { id: MOCK_SNAP_ID } },
497+
};
498+
const account2 = {
499+
id: 'account-2',
500+
address: '0x2',
501+
metadata: { snap: { id: MOCK_SNAP_ID } },
502+
};
503+
const account3 = {
504+
id: 'account-3',
505+
address: '0x3',
506+
metadata: { snap: { id: MOCK_OTHER_SNAP_ID } },
507+
};
508+
const orphanAccount = {
509+
id: 'orphan',
510+
address: '0x4',
511+
metadata: {},
512+
};
513+
const legacyKeyring = {
514+
type: SNAP_KEYRING_TYPE,
515+
listAccounts: jest
516+
.fn()
517+
.mockReturnValue([account1, account2, account3, orphanAccount]),
518+
};
519+
const { service, mocks } = setup();
520+
mocks.KeyringController.withController.mockImplementation(
521+
async (operation) =>
522+
operation({
523+
keyrings: [
524+
{ keyring: legacyKeyring, metadata: { id: legacyKeyringId } },
525+
],
526+
addNewKeyring,
527+
removeKeyring,
528+
}),
529+
);
530+
531+
await service.migrate();
532+
533+
expect(addNewKeyring).toHaveBeenCalledTimes(2);
534+
expect(addNewKeyring).toHaveBeenCalledWith(KeyringType.Snap, {
535+
snapId: MOCK_SNAP_ID,
536+
accounts: {
537+
[account1.id]: { id: account1.id, address: account1.address },
538+
[account2.id]: { id: account2.id, address: account2.address },
539+
},
540+
});
541+
expect(addNewKeyring).toHaveBeenCalledWith(KeyringType.Snap, {
542+
snapId: MOCK_OTHER_SNAP_ID,
543+
accounts: {
544+
[account3.id]: { id: account3.id, address: account3.address },
545+
},
546+
});
547+
expect(removeKeyring).toHaveBeenCalledWith(legacyKeyringId);
548+
});
549+
});
550+
461551
describe('ensureReady', () => {
462552
it('throws when the Snap is not tracked', async () => {
463553
const { service } = setup();
@@ -489,6 +579,73 @@ describe('SnapAccountService', () => {
489579
expect(await service.ensureReady(MOCK_SNAP_ID)).toBeUndefined();
490580
});
491581

582+
it('runs the migration before checking platform readiness', async () => {
583+
const { service, mocks } = setup({
584+
runnableSnaps: [buildSnap(MOCK_SNAP_ID, true)],
585+
});
586+
mocks.KeyringController.withController.mockResolvedValue(undefined);
587+
588+
await service.init();
589+
// `migrate` is invoked once + `#createKeyringForSnap` is invoked once
590+
// (the cached migrate call is a no-op on subsequent calls).
591+
await service.ensureReady(MOCK_SNAP_ID);
592+
593+
expect(mocks.KeyringController.withController).toHaveBeenCalledTimes(2);
594+
});
595+
596+
it('creates a v2 keyring for the Snap when one does not exist yet', async () => {
597+
const addNewKeyring = jest.fn().mockResolvedValue(undefined);
598+
const removeKeyring = jest.fn().mockResolvedValue(undefined);
599+
const { service, mocks } = setup({
600+
runnableSnaps: [buildSnap(MOCK_SNAP_ID, true)],
601+
});
602+
mocks.KeyringController.withController.mockImplementation(
603+
async (operation) =>
604+
operation({ keyrings: [], addNewKeyring, removeKeyring }),
605+
);
606+
607+
await service.init();
608+
await service.ensureReady(MOCK_SNAP_ID);
609+
610+
expect(addNewKeyring).toHaveBeenCalledWith(KeyringType.Snap, {
611+
snapId: MOCK_SNAP_ID,
612+
accounts: {},
613+
});
614+
});
615+
616+
it('does not create a v2 keyring when one already exists for the Snap', async () => {
617+
const addNewKeyring = jest.fn().mockResolvedValue(undefined);
618+
const removeKeyring = jest.fn().mockResolvedValue(undefined);
619+
const { service, mocks } = setup({
620+
runnableSnaps: [buildSnap(MOCK_SNAP_ID, true)],
621+
});
622+
// First call: migration (no legacy snap keyring → early return).
623+
// Second call: ensureReady keyring check (snap keyring already exists).
624+
mocks.KeyringController.withController
625+
.mockImplementationOnce(async (operation) =>
626+
operation({ keyrings: [], addNewKeyring, removeKeyring }),
627+
)
628+
.mockImplementationOnce(async (operation) =>
629+
operation({
630+
keyrings: [
631+
{
632+
keyringV2: {
633+
type: KeyringType.Snap,
634+
snapId: MOCK_SNAP_ID,
635+
},
636+
},
637+
],
638+
addNewKeyring,
639+
removeKeyring,
640+
}),
641+
);
642+
643+
await service.init();
644+
await service.ensureReady(MOCK_SNAP_ID);
645+
646+
expect(addNewKeyring).not.toHaveBeenCalled();
647+
});
648+
492649
it('waits for the Snap platform to become ready', async () => {
493650
const { service, rootMessenger } = setup({
494651
snapIsReady: false,
@@ -525,9 +682,9 @@ describe('SnapAccountService', () => {
525682
return undefined;
526683
});
527684

528-
// Flush microtasks so #waitForSnapKeyring subscribes.
529-
await Promise.resolve();
530-
await Promise.resolve();
685+
// Flush microtasks so migration completes and #waitForSnapKeyring
686+
// subscribes.
687+
await flushMicrotasks();
531688

532689
expect(resolved).toBe(false);
533690

@@ -584,7 +741,7 @@ describe('SnapAccountService', () => {
584741
return undefined;
585742
});
586743

587-
await Promise.resolve();
744+
await flushMicrotasks();
588745
expect(ensureOnboardingComplete).toHaveBeenCalledTimes(1);
589746
expect(resolved).toBe(false);
590747

0 commit comments

Comments
 (0)