Skip to content

Commit 6b67d84

Browse files
authored
feat(snap-account-service): track account management Snaps + add :getSnaps (#8725)
## Explanation Tracking account management Snaps so we can "restrict" the use of this service only for those Snaps. This will also be used later on to initialize their associated `SnapKeyring` v2. ## References N/A ## Checklist - [ ] I've updated the test suite for new or updated code as appropriate - [ ] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [ ] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/processes/updating-changelogs.md) - [ ] I've introduced [breaking changes](https://github.com/MetaMask/core/tree/main/docs/processes/breaking-changes.md) in this PR and have prepared draft pull requests for clients and consumer packages to resolve them <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Changes `SnapAccountService.ensureReady` semantics to reject unknown/uninitialized Snap IDs and adds new messenger dependencies on `SnapController` lifecycle events, which could break existing consumers or event wiring if not updated. > > **Overview** > Adds `SnapTracker`, which seeds and maintains an in-memory set of *account-management Snaps* (installed/enabled, not blocked, and declaring `endowment:keyring`) based on `SnapController:getRunnableSnaps` plus `SnapController` lifecycle events. > > `SnapAccountService` now initializes this tracker in `init()`, exposes a new `getSnaps()` messenger method, and updates `ensureReady(snapId)` to **throw `Unknown snap: "<id>"`** unless the snap is currently tracked (effectively requiring callers to run `init()` and only use eligible Snaps). Tests and exports/changelog are updated accordingly, and the service messenger contract expands to include the new `SnapController` actions/events. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 48b03c9. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent c351602 commit 6b67d84

7 files changed

Lines changed: 784 additions & 32 deletions

File tree

packages/snap-account-service/CHANGELOG.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,22 @@ 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`, which waits for the Snap platform to be ready and for a Snap keyring to appear in `KeyringController` state before allowing Snap account operations ([#8715](https://github.com/MetaMask/core/pull/8715))
13+
- Add `SnapPlatformWatcher` and `SnapAccountService.ensureReady` ([#8715](https://github.com/MetaMask/core/pull/8715)), ([#8725](https://github.com/MetaMask/core/pull/8725))
14+
- Waits for the Snap platform to be ready and for a Snap keyring to appear in `KeyringController` state before allowing Snap account operations.
15+
- Callers must ensure `init()` has run and the Snap is currently installed, enabled, non-blocked, and declares `endowment:keyring`.
1416
- `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).
17+
- `SnapAccountService.ensureReady` now throws `Unknown snap: "<id>"` when called with a Snap ID that isn't tracked as an account-management Snap.
1518
- Add `config` option to `SnapAccountService` constructor with a `snapPlatformWatcher` field exposing `ensureOnboardingComplete` and `snapKeyringWaitTimeoutMs` ([#8715](https://github.com/MetaMask/core/pull/8715))
1619
- Export `SnapAccountServiceConfig` and `SnapPlatformWatcherConfig` types.
1720
- Add `@metamask/keyring-controller` dependency ([#8715](https://github.com/MetaMask/core/pull/8715))
1821
- The service messenger now requires the `KeyringController:getState` action and `KeyringController:stateChange` event.
22+
- Add `getSnaps` action to `SnapAccountService`, returning the IDs of installed, enabled, non-blocked Snaps that declare the `endowment:keyring` permission ([#8725](https://github.com/MetaMask/core/pull/8725))
23+
- Export `SnapAccountServiceGetSnapsAction` type.
24+
- The service now seeds its internal set from `SnapController:getRunnableSnaps` during `init()` and keeps it in sync via `SnapController` lifecycle events (`snapInstalled`, `snapEnabled`, `snapDisabled`, `snapBlocked`, `snapUninstalled`).
25+
- The service messenger now requires the `SnapController:getRunnableSnaps` action and the five lifecycle events listed above.
1926

2027
### Changed
2128

2229
- Bump `@metamask/messenger` from `^1.1.1` to `^1.2.0` ([#8632](https://github.com/MetaMask/core/pull/8632))
23-
- **BREAKING:** Remove the top-level `ensureOnboardingComplete` option from `SnapAccountServiceOptions` ([#8715](https://github.com/MetaMask/core/pull/8715))
24-
- Pass the callback via `config.snapPlatformWatcher.ensureOnboardingComplete` instead.
2530

2631
[Unreleased]: https://github.com/MetaMask/core/

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

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,27 @@
55

66
import type { SnapAccountService } from './SnapAccountService';
77

8+
/**
9+
* Returns the IDs of all currently tracked account-management Snaps —
10+
* Snaps that are installed, enabled, not blocked, and have the
11+
* `endowment:keyring` permission.
12+
*
13+
* @returns The IDs of tracked account-management Snaps.
14+
*/
15+
export type SnapAccountServiceGetSnapsAction = {
16+
type: `SnapAccountService:getSnaps`;
17+
handler: SnapAccountService['getSnaps'];
18+
};
19+
820
/**
921
* Ensures everything is ready to use Snap accounts for the given Snap.
10-
* 1. Waits for the Snap platform to be fully started.
22+
* 1. Validates that `snapId` is a tracked account-management Snap.
23+
* 2. Waits for the Snap platform to be fully started.
1124
*
1225
* Safe to call concurrently — each step is idempotent or mutex-protected.
1326
*
14-
* @param _snapId - ID of the Snap to ensure readiness for.
27+
* @param snapId - ID of the Snap to ensure readiness for.
28+
* @throws If `snapId` is not a tracked account-management Snap.
1529
*/
1630
export type SnapAccountServiceEnsureReadyAction = {
1731
type: `SnapAccountService:ensureReady`;
@@ -22,4 +36,5 @@ export type SnapAccountServiceEnsureReadyAction = {
2236
* Union of all SnapAccountService action types.
2337
*/
2438
export type SnapAccountServiceMethodActions =
25-
SnapAccountServiceEnsureReadyAction;
39+
| SnapAccountServiceGetSnapsAction
40+
| SnapAccountServiceEnsureReadyAction;

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

Lines changed: 113 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,43 @@
1-
import { KeyringTypes } from '@metamask/keyring-controller';
1+
import {
2+
KeyringControllerState,
3+
KeyringTypes,
4+
} from '@metamask/keyring-controller';
25
import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger';
36
import type {
47
MockAnyNamespace,
58
MessengerActions,
69
MessengerEvents,
710
} from '@metamask/messenger';
811
import type { SnapControllerState } from '@metamask/snaps-controllers';
12+
import type { SnapId } from '@metamask/snaps-sdk';
13+
import type { TruncatedSnap } from '@metamask/snaps-utils';
914

1015
import type {
1116
SnapAccountServiceMessenger,
1217
SnapAccountServiceOptions,
1318
} from './SnapAccountService';
1419
import { SnapAccountService } from './SnapAccountService';
1520

16-
/**
17-
* The type of the messenger populated with all external actions and events
18-
* required by the service under test.
19-
*/
2021
type RootMessenger = Messenger<
2122
MockAnyNamespace,
2223
MessengerActions<SnapAccountServiceMessenger>,
2324
MessengerEvents<SnapAccountServiceMessenger>
2425
>;
2526

26-
/**
27-
* Mock objects for all external dependencies of {@link SnapAccountService}.
28-
*/
27+
/** Mock keyring controller state type for tests. */
28+
type MockKeyringControllerState = Pick<KeyringControllerState, 'keyrings'>;
29+
30+
/** Mock truncated snap type for tests. */
31+
type MockTruncatedSnap = Pick<
32+
TruncatedSnap,
33+
'id' | 'initialPermissions' | 'enabled' | 'blocked'
34+
>;
35+
2936
type Mocks = {
3037
// eslint-disable-next-line @typescript-eslint/naming-convention
3138
SnapController: {
3239
getState: jest.MockedFunction<() => SnapControllerState>;
40+
getRunnableSnaps: jest.MockedFunction<() => TruncatedSnap[]>;
3341
};
3442
// eslint-disable-next-line @typescript-eslint/naming-convention
3543
KeyringController: {
@@ -62,8 +70,22 @@ function getMessenger(
6270
});
6371
rootMessenger.delegate({
6472
messenger,
65-
actions: ['SnapController:getState', 'KeyringController:getState'],
66-
events: ['SnapController:stateChange', 'KeyringController:stateChange'],
73+
actions: [
74+
'SnapController:getState',
75+
'SnapController:getSnap',
76+
'SnapController:getRunnableSnaps',
77+
'KeyringController:getState',
78+
],
79+
events: [
80+
'SnapController:stateChange',
81+
'SnapController:snapInstalled',
82+
'SnapController:snapEnabled',
83+
'SnapController:snapDisabled',
84+
'SnapController:snapBlocked',
85+
'SnapController:snapUnblocked',
86+
'SnapController:snapUninstalled',
87+
'KeyringController:stateChange',
88+
],
6789
});
6890
return messenger;
6991
}
@@ -97,28 +119,46 @@ function publishKeyrings(
97119
): void {
98120
rootMessenger.publish(
99121
'KeyringController:stateChange',
100-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
101-
{ keyrings } as any,
122+
{ keyrings } as MockKeyringControllerState as KeyringControllerState,
102123
[],
103124
);
104125
}
105126

127+
/**
128+
* Builds a minimal `TruncatedSnap` for tests.
129+
*
130+
* @param id - The Snap ID.
131+
* @param hasKeyring - Whether the Snap declares the `endowment:keyring` initial permission.
132+
* @returns A minimal `TruncatedSnap`.
133+
*/
134+
function buildSnap(id: string, hasKeyring: boolean): TruncatedSnap {
135+
return {
136+
id: id as SnapId,
137+
initialPermissions: hasKeyring ? { 'endowment:keyring': {} } : {},
138+
enabled: true,
139+
blocked: false,
140+
} as MockTruncatedSnap as TruncatedSnap;
141+
}
142+
106143
/**
107144
* Constructs the service under test with sensible defaults.
108145
*
109146
* @param args - The arguments to this function.
110147
* @param args.snapIsReady - Initial value of `SnapController.isReady`.
111148
* @param args.keyrings - Initial keyrings returned by `KeyringController:getState`.
149+
* @param args.runnableSnaps - Snaps returned by `SnapController:getRunnableSnaps`.
112150
* @param args.config - Optional service config.
113151
* @returns The new service, root messenger, service messenger, and mocks.
114152
*/
115153
function setup({
116154
snapIsReady = true,
117155
keyrings = [{ type: KeyringTypes.snap }],
156+
runnableSnaps = [],
118157
config,
119158
}: {
120159
snapIsReady?: boolean;
121160
keyrings?: { type: string }[];
161+
runnableSnaps?: TruncatedSnap[];
122162
config?: SnapAccountServiceOptions['config'];
123163
} = {}): {
124164
service: SnapAccountService;
@@ -134,6 +174,7 @@ function setup({
134174
getState: jest
135175
.fn()
136176
.mockReturnValue({ isReady: snapIsReady } as SnapControllerState),
177+
getRunnableSnaps: jest.fn().mockReturnValue(runnableSnaps),
137178
},
138179
KeyringController: {
139180
getState: jest.fn().mockReturnValue({ keyrings }),
@@ -144,6 +185,10 @@ function setup({
144185
'SnapController:getState',
145186
mocks.SnapController.getState,
146187
);
188+
rootMessenger.registerActionHandler(
189+
'SnapController:getRunnableSnaps',
190+
mocks.SnapController.getRunnableSnaps,
191+
);
147192
rootMessenger.registerActionHandler(
148193
'KeyringController:getState',
149194
mocks.KeyringController.getState,
@@ -154,7 +199,7 @@ function setup({
154199
return { service, rootMessenger, messenger, mocks };
155200
}
156201

157-
const MOCK_SNAP_ID = 'npm:@metamask/mock-snap' as const;
202+
const MOCK_SNAP_ID = 'npm:@metamask/mock-snap' as SnapId;
158203

159204
describe('SnapAccountService', () => {
160205
describe('init', () => {
@@ -165,15 +210,56 @@ describe('SnapAccountService', () => {
165210
});
166211
});
167212

213+
describe('getSnaps', () => {
214+
it('exposes tracked Snaps seeded by init', async () => {
215+
const { service } = setup({
216+
runnableSnaps: [buildSnap(MOCK_SNAP_ID, true)],
217+
});
218+
219+
await service.init();
220+
221+
expect(service.getSnaps()).toStrictEqual([MOCK_SNAP_ID]);
222+
});
223+
});
224+
168225
describe('ensureReady', () => {
169-
it('resolves when platform is already ready', async () => {
226+
it('throws when the Snap is not tracked', async () => {
170227
const { service } = setup();
171228

229+
await service.init();
230+
231+
await expect(service.ensureReady(MOCK_SNAP_ID)).rejects.toThrow(
232+
`Unknown snap: "${MOCK_SNAP_ID}"`,
233+
);
234+
});
235+
236+
it('throws before init even for runnable Snaps', async () => {
237+
const { service } = setup({
238+
runnableSnaps: [buildSnap(MOCK_SNAP_ID, true)],
239+
});
240+
241+
await expect(service.ensureReady(MOCK_SNAP_ID)).rejects.toThrow(
242+
`Unknown snap: "${MOCK_SNAP_ID}"`,
243+
);
244+
});
245+
246+
it('resolves when platform is already ready', async () => {
247+
const { service } = setup({
248+
runnableSnaps: [buildSnap(MOCK_SNAP_ID, true)],
249+
});
250+
251+
await service.init();
252+
172253
expect(await service.ensureReady(MOCK_SNAP_ID)).toBeUndefined();
173254
});
174255

175256
it('waits for the Snap platform to become ready', async () => {
176-
const { service, rootMessenger } = setup({ snapIsReady: false });
257+
const { service, rootMessenger } = setup({
258+
snapIsReady: false,
259+
runnableSnaps: [buildSnap(MOCK_SNAP_ID, true)],
260+
});
261+
262+
await service.init();
177263

178264
let resolved = false;
179265
const ensurePromise = service.ensureReady(MOCK_SNAP_ID).then(() => {
@@ -190,7 +276,12 @@ describe('SnapAccountService', () => {
190276
});
191277

192278
it('waits for the Snap keyring to appear via KeyringController:stateChange', async () => {
193-
const { service, rootMessenger } = setup({ keyrings: [] });
279+
const { service, rootMessenger } = setup({
280+
keyrings: [],
281+
runnableSnaps: [buildSnap(MOCK_SNAP_ID, true)],
282+
});
283+
284+
await service.init();
194285

195286
let resolved = false;
196287
const ensurePromise = service.ensureReady(MOCK_SNAP_ID).then(() => {
@@ -213,11 +304,14 @@ describe('SnapAccountService', () => {
213304
it('rejects if the Snap keyring does not appear within snapKeyringWaitTimeoutMs', async () => {
214305
const { service } = setup({
215306
keyrings: [],
307+
runnableSnaps: [buildSnap(MOCK_SNAP_ID, true)],
216308
config: {
217309
snapPlatformWatcher: { snapKeyringWaitTimeoutMs: 1_000 },
218310
},
219311
});
220312

313+
await service.init();
314+
221315
jest.useFakeTimers();
222316
const ensurePromise = service.ensureReady(MOCK_SNAP_ID);
223317
// Attach rejection handler before advancing timers to avoid unhandled rejection.
@@ -242,9 +336,12 @@ describe('SnapAccountService', () => {
242336
);
243337

244338
const { service } = setup({
339+
runnableSnaps: [buildSnap(MOCK_SNAP_ID, true)],
245340
config: { snapPlatformWatcher: { ensureOnboardingComplete } },
246341
});
247342

343+
await service.init();
344+
248345
let resolved = false;
249346
const ensurePromise = service.ensureReady(MOCK_SNAP_ID).then(() => {
250347
resolved = true;

0 commit comments

Comments
 (0)