diff --git a/packages/snap-account-service/CHANGELOG.md b/packages/snap-account-service/CHANGELOG.md index 43c2339e37..363e6fcbf2 100644 --- a/packages/snap-account-service/CHANGELOG.md +++ b/packages/snap-account-service/CHANGELOG.md @@ -10,17 +10,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add `SnapAccountService` ([#8414](https://github.com/MetaMask/core/pull/8414)) -- 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)) +- Add `SnapPlatformWatcher` and `SnapAccountService.ensureReady` ([#8715](https://github.com/MetaMask/core/pull/8715)), ([#8725](https://github.com/MetaMask/core/pull/8725)) + - Waits for the Snap platform to be ready and for a Snap keyring to appear in `KeyringController` state before allowing Snap account operations. + - Callers must ensure `init()` has run and the Snap is currently installed, enabled, non-blocked, and declares `endowment:keyring`. - `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). + - `SnapAccountService.ensureReady` now throws `Unknown snap: ""` when called with a Snap ID that isn't tracked as an account-management Snap. - Add `config` option to `SnapAccountService` constructor with a `snapPlatformWatcher` field exposing `ensureOnboardingComplete` and `snapKeyringWaitTimeoutMs` ([#8715](https://github.com/MetaMask/core/pull/8715)) - Export `SnapAccountServiceConfig` and `SnapPlatformWatcherConfig` types. - Add `@metamask/keyring-controller` dependency ([#8715](https://github.com/MetaMask/core/pull/8715)) - The service messenger now requires the `KeyringController:getState` action and `KeyringController:stateChange` event. +- 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)) + - Export `SnapAccountServiceGetSnapsAction` type. + - 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`). + - The service messenger now requires the `SnapController:getRunnableSnaps` action and the five lifecycle events listed above. ### Changed - Bump `@metamask/messenger` from `^1.1.1` to `^1.2.0` ([#8632](https://github.com/MetaMask/core/pull/8632)) -- **BREAKING:** Remove the top-level `ensureOnboardingComplete` option from `SnapAccountServiceOptions` ([#8715](https://github.com/MetaMask/core/pull/8715)) - - Pass the callback via `config.snapPlatformWatcher.ensureOnboardingComplete` instead. [Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/snap-account-service/src/SnapAccountService-method-action-types.ts b/packages/snap-account-service/src/SnapAccountService-method-action-types.ts index a206cc87a4..1be1270a0a 100644 --- a/packages/snap-account-service/src/SnapAccountService-method-action-types.ts +++ b/packages/snap-account-service/src/SnapAccountService-method-action-types.ts @@ -5,13 +5,27 @@ import type { SnapAccountService } from './SnapAccountService'; +/** + * Returns the IDs of all currently tracked account-management Snaps — + * Snaps that are installed, enabled, not blocked, and have the + * `endowment:keyring` permission. + * + * @returns The IDs of tracked account-management Snaps. + */ +export type SnapAccountServiceGetSnapsAction = { + type: `SnapAccountService:getSnaps`; + handler: SnapAccountService['getSnaps']; +}; + /** * Ensures everything is ready to use Snap accounts for the given Snap. - * 1. Waits for the Snap platform to be fully started. + * 1. Validates that `snapId` is a tracked account-management Snap. + * 2. Waits for the Snap platform to be fully started. * * Safe to call concurrently — each step is idempotent or mutex-protected. * - * @param _snapId - ID of the Snap to ensure readiness for. + * @param snapId - ID of the Snap to ensure readiness for. + * @throws If `snapId` is not a tracked account-management Snap. */ export type SnapAccountServiceEnsureReadyAction = { type: `SnapAccountService:ensureReady`; @@ -22,4 +36,5 @@ export type SnapAccountServiceEnsureReadyAction = { * Union of all SnapAccountService action types. */ export type SnapAccountServiceMethodActions = - SnapAccountServiceEnsureReadyAction; + | SnapAccountServiceGetSnapsAction + | SnapAccountServiceEnsureReadyAction; diff --git a/packages/snap-account-service/src/SnapAccountService.test.ts b/packages/snap-account-service/src/SnapAccountService.test.ts index d4c401e2f2..ebf32b2473 100644 --- a/packages/snap-account-service/src/SnapAccountService.test.ts +++ b/packages/snap-account-service/src/SnapAccountService.test.ts @@ -1,4 +1,7 @@ -import { KeyringTypes } from '@metamask/keyring-controller'; +import { + KeyringControllerState, + KeyringTypes, +} from '@metamask/keyring-controller'; import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; import type { MockAnyNamespace, @@ -6,6 +9,8 @@ import type { MessengerEvents, } from '@metamask/messenger'; import type { SnapControllerState } from '@metamask/snaps-controllers'; +import type { SnapId } from '@metamask/snaps-sdk'; +import type { TruncatedSnap } from '@metamask/snaps-utils'; import type { SnapAccountServiceMessenger, @@ -13,23 +18,26 @@ import type { } from './SnapAccountService'; import { SnapAccountService } from './SnapAccountService'; -/** - * The type of the messenger populated with all external actions and events - * required by the service under test. - */ type RootMessenger = Messenger< MockAnyNamespace, MessengerActions, MessengerEvents >; -/** - * Mock objects for all external dependencies of {@link SnapAccountService}. - */ +/** Mock keyring controller state type for tests. */ +type MockKeyringControllerState = Pick; + +/** Mock truncated snap type for tests. */ +type MockTruncatedSnap = Pick< + TruncatedSnap, + 'id' | 'initialPermissions' | 'enabled' | 'blocked' +>; + type Mocks = { // eslint-disable-next-line @typescript-eslint/naming-convention SnapController: { getState: jest.MockedFunction<() => SnapControllerState>; + getRunnableSnaps: jest.MockedFunction<() => TruncatedSnap[]>; }; // eslint-disable-next-line @typescript-eslint/naming-convention KeyringController: { @@ -62,8 +70,22 @@ function getMessenger( }); rootMessenger.delegate({ messenger, - actions: ['SnapController:getState', 'KeyringController:getState'], - events: ['SnapController:stateChange', 'KeyringController:stateChange'], + actions: [ + 'SnapController:getState', + 'SnapController:getSnap', + 'SnapController:getRunnableSnaps', + 'KeyringController:getState', + ], + events: [ + 'SnapController:stateChange', + 'SnapController:snapInstalled', + 'SnapController:snapEnabled', + 'SnapController:snapDisabled', + 'SnapController:snapBlocked', + 'SnapController:snapUnblocked', + 'SnapController:snapUninstalled', + 'KeyringController:stateChange', + ], }); return messenger; } @@ -97,28 +119,46 @@ function publishKeyrings( ): void { rootMessenger.publish( 'KeyringController:stateChange', - // eslint-disable-next-line @typescript-eslint/no-explicit-any - { keyrings } as any, + { keyrings } as MockKeyringControllerState as KeyringControllerState, [], ); } +/** + * Builds a minimal `TruncatedSnap` for tests. + * + * @param id - The Snap ID. + * @param hasKeyring - Whether the Snap declares the `endowment:keyring` initial permission. + * @returns A minimal `TruncatedSnap`. + */ +function buildSnap(id: string, hasKeyring: boolean): TruncatedSnap { + return { + id: id as SnapId, + initialPermissions: hasKeyring ? { 'endowment:keyring': {} } : {}, + enabled: true, + blocked: false, + } as MockTruncatedSnap as TruncatedSnap; +} + /** * Constructs the service under test with sensible defaults. * * @param args - The arguments to this function. * @param args.snapIsReady - Initial value of `SnapController.isReady`. * @param args.keyrings - Initial keyrings returned by `KeyringController:getState`. + * @param args.runnableSnaps - Snaps returned by `SnapController:getRunnableSnaps`. * @param args.config - Optional service config. * @returns The new service, root messenger, service messenger, and mocks. */ function setup({ snapIsReady = true, keyrings = [{ type: KeyringTypes.snap }], + runnableSnaps = [], config, }: { snapIsReady?: boolean; keyrings?: { type: string }[]; + runnableSnaps?: TruncatedSnap[]; config?: SnapAccountServiceOptions['config']; } = {}): { service: SnapAccountService; @@ -134,6 +174,7 @@ function setup({ getState: jest .fn() .mockReturnValue({ isReady: snapIsReady } as SnapControllerState), + getRunnableSnaps: jest.fn().mockReturnValue(runnableSnaps), }, KeyringController: { getState: jest.fn().mockReturnValue({ keyrings }), @@ -144,6 +185,10 @@ function setup({ 'SnapController:getState', mocks.SnapController.getState, ); + rootMessenger.registerActionHandler( + 'SnapController:getRunnableSnaps', + mocks.SnapController.getRunnableSnaps, + ); rootMessenger.registerActionHandler( 'KeyringController:getState', mocks.KeyringController.getState, @@ -154,7 +199,7 @@ function setup({ return { service, rootMessenger, messenger, mocks }; } -const MOCK_SNAP_ID = 'npm:@metamask/mock-snap' as const; +const MOCK_SNAP_ID = 'npm:@metamask/mock-snap' as SnapId; describe('SnapAccountService', () => { describe('init', () => { @@ -165,15 +210,56 @@ describe('SnapAccountService', () => { }); }); + describe('getSnaps', () => { + it('exposes tracked Snaps seeded by init', async () => { + const { service } = setup({ + runnableSnaps: [buildSnap(MOCK_SNAP_ID, true)], + }); + + await service.init(); + + expect(service.getSnaps()).toStrictEqual([MOCK_SNAP_ID]); + }); + }); + describe('ensureReady', () => { - it('resolves when platform is already ready', async () => { + it('throws when the Snap is not tracked', async () => { const { service } = setup(); + await service.init(); + + await expect(service.ensureReady(MOCK_SNAP_ID)).rejects.toThrow( + `Unknown snap: "${MOCK_SNAP_ID}"`, + ); + }); + + it('throws before init even for runnable Snaps', async () => { + const { service } = setup({ + runnableSnaps: [buildSnap(MOCK_SNAP_ID, true)], + }); + + await expect(service.ensureReady(MOCK_SNAP_ID)).rejects.toThrow( + `Unknown snap: "${MOCK_SNAP_ID}"`, + ); + }); + + it('resolves when platform is already ready', async () => { + const { service } = setup({ + runnableSnaps: [buildSnap(MOCK_SNAP_ID, true)], + }); + + await service.init(); + expect(await service.ensureReady(MOCK_SNAP_ID)).toBeUndefined(); }); it('waits for the Snap platform to become ready', async () => { - const { service, rootMessenger } = setup({ snapIsReady: false }); + const { service, rootMessenger } = setup({ + snapIsReady: false, + runnableSnaps: [buildSnap(MOCK_SNAP_ID, true)], + }); + + await service.init(); let resolved = false; const ensurePromise = service.ensureReady(MOCK_SNAP_ID).then(() => { @@ -190,7 +276,12 @@ describe('SnapAccountService', () => { }); it('waits for the Snap keyring to appear via KeyringController:stateChange', async () => { - const { service, rootMessenger } = setup({ keyrings: [] }); + const { service, rootMessenger } = setup({ + keyrings: [], + runnableSnaps: [buildSnap(MOCK_SNAP_ID, true)], + }); + + await service.init(); let resolved = false; const ensurePromise = service.ensureReady(MOCK_SNAP_ID).then(() => { @@ -213,11 +304,14 @@ describe('SnapAccountService', () => { it('rejects if the Snap keyring does not appear within snapKeyringWaitTimeoutMs', async () => { const { service } = setup({ keyrings: [], + runnableSnaps: [buildSnap(MOCK_SNAP_ID, true)], config: { snapPlatformWatcher: { snapKeyringWaitTimeoutMs: 1_000 }, }, }); + await service.init(); + jest.useFakeTimers(); const ensurePromise = service.ensureReady(MOCK_SNAP_ID); // Attach rejection handler before advancing timers to avoid unhandled rejection. @@ -242,9 +336,12 @@ describe('SnapAccountService', () => { ); const { service } = setup({ + runnableSnaps: [buildSnap(MOCK_SNAP_ID, true)], config: { snapPlatformWatcher: { ensureOnboardingComplete } }, }); + await service.init(); + let resolved = false; const ensurePromise = service.ensureReady(MOCK_SNAP_ID).then(() => { resolved = true; diff --git a/packages/snap-account-service/src/SnapAccountService.ts b/packages/snap-account-service/src/SnapAccountService.ts index e43cb700b8..56b6a389e5 100644 --- a/packages/snap-account-service/src/SnapAccountService.ts +++ b/packages/snap-account-service/src/SnapAccountService.ts @@ -4,14 +4,26 @@ import type { } from '@metamask/keyring-controller'; import type { Messenger } from '@metamask/messenger'; import type { + SnapControllerGetRunnableSnapsAction, + SnapControllerGetSnapAction, SnapControllerGetStateAction, + SnapControllerSnapBlockedEvent, + SnapControllerSnapDisabledEvent, + SnapControllerSnapEnabledEvent, + SnapControllerSnapInstalledEvent, + SnapControllerSnapUnblockedEvent, + SnapControllerSnapUninstalledEvent, SnapControllerStateChangeEvent, } from '@metamask/snaps-controllers'; import { SnapId } from '@metamask/snaps-sdk'; -import type { SnapAccountServiceEnsureReadyAction } from './SnapAccountService-method-action-types'; +import type { + SnapAccountServiceEnsureReadyAction, + SnapAccountServiceGetSnapsAction, +} from './SnapAccountService-method-action-types'; import { SnapPlatformWatcher } from './SnapPlatformWatcher'; import type { SnapPlatformWatcherConfig } from './SnapPlatformWatcher'; +import { SnapTracker } from './SnapTracker'; /** * The name of the {@link SnapAccountService}, used to namespace the service's @@ -23,18 +35,22 @@ export const serviceName = 'SnapAccountService'; * All of the methods within {@link SnapAccountService} that are exposed via * the messenger. */ -const MESSENGER_EXPOSED_METHODS = ['ensureReady'] as const; +const MESSENGER_EXPOSED_METHODS = ['ensureReady', 'getSnaps'] as const; /** * Actions that {@link SnapAccountService} exposes to other consumers. */ -export type SnapAccountServiceActions = SnapAccountServiceEnsureReadyAction; +export type SnapAccountServiceActions = + | SnapAccountServiceEnsureReadyAction + | SnapAccountServiceGetSnapsAction; /** * Actions from other messengers that {@link SnapAccountService} calls. */ type AllowedActions = | SnapControllerGetStateAction + | SnapControllerGetSnapAction + | SnapControllerGetRunnableSnapsAction | KeyringControllerGetStateAction; /** @@ -47,6 +63,12 @@ export type SnapAccountServiceEvents = never; */ type AllowedEvents = | SnapControllerStateChangeEvent + | SnapControllerSnapInstalledEvent + | SnapControllerSnapEnabledEvent + | SnapControllerSnapDisabledEvent + | SnapControllerSnapBlockedEvent + | SnapControllerSnapUnblockedEvent + | SnapControllerSnapUninstalledEvent | KeyringControllerStateChangeEvent; /** @@ -87,6 +109,8 @@ export class SnapAccountService { readonly #watcher: SnapPlatformWatcher; + readonly #tracker: SnapTracker; + /** * Constructs a new {@link SnapAccountService}. * @@ -101,6 +125,7 @@ export class SnapAccountService { messenger, config?.snapPlatformWatcher, ); + this.#tracker = new SnapTracker(messenger); this.#messenger.registerMethodActionHandlers( this, @@ -110,22 +135,42 @@ export class SnapAccountService { /** * Initializes the snap account service. + * + * Seeds the internal set of account-management Snaps from + * `SnapController:getRunnableSnaps`, then starts processing lifecycle + * events. */ async init(): Promise { - // TODO: Add initialization logic here. + await this.#tracker.init(); + } + + /** + * Returns the IDs of all currently tracked account-management Snaps — + * Snaps that are installed, enabled, not blocked, and have the + * `endowment:keyring` permission. + * + * @returns The IDs of tracked account-management Snaps. + */ + getSnaps(): SnapId[] { + return this.#tracker.getSnaps(); } /** * Ensures everything is ready to use Snap accounts for the given Snap. - * 1. Waits for the Snap platform to be fully started. + * 1. Validates that `snapId` is a tracked account-management Snap. + * 2. Waits for the Snap platform to be fully started. * * Safe to call concurrently — each step is idempotent or mutex-protected. * - * @param _snapId - ID of the Snap to ensure readiness for. + * @param snapId - ID of the Snap to ensure readiness for. + * @throws If `snapId` is not a tracked account-management Snap. */ - async ensureReady(_snapId: SnapId): Promise { - // Lastly, before doing anything with our Snap, we need to make sure the - // platform is ready to process requests. + async ensureReady(snapId: SnapId): Promise { + if (!this.#tracker.canUse(snapId)) { + throw new Error(`Unknown snap: "${snapId}"`); + } + // Before doing anything with our Snap, we need to make sure the platform + // is ready to process requests. await this.#watcher.ensureCanUseSnapPlatform(); } } diff --git a/packages/snap-account-service/src/SnapTracker.test.ts b/packages/snap-account-service/src/SnapTracker.test.ts new file mode 100644 index 0000000000..64b13fa450 --- /dev/null +++ b/packages/snap-account-service/src/SnapTracker.test.ts @@ -0,0 +1,432 @@ +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MockAnyNamespace, + MessengerActions, + MessengerEvents, +} from '@metamask/messenger'; +import type { SnapId } from '@metamask/snaps-sdk'; +import type { TruncatedSnap } from '@metamask/snaps-utils'; + +import type { SnapAccountServiceMessenger } from './SnapAccountService'; +import { SnapTracker } from './SnapTracker'; + +type RootMessenger = Messenger< + MockAnyNamespace, + MessengerActions, + MessengerEvents +>; + +type MockTruncatedSnap = Pick< + TruncatedSnap, + 'id' | 'initialPermissions' | 'enabled' | 'blocked' +>; + +type Mocks = { + // eslint-disable-next-line @typescript-eslint/naming-convention + SnapController: { + getSnap: jest.MockedFunction<(snapId: string) => TruncatedSnap | null>; + getRunnableSnaps: jest.MockedFunction<() => TruncatedSnap[]>; + }; +}; + +/** + * Constructs the root messenger for the tracker under test. + * + * @returns The root messenger. + */ +function getRootMessenger(): RootMessenger { + return new Messenger({ namespace: MOCK_ANY_NAMESPACE }); +} + +/** + * Constructs the messenger for the tracker under test, and delegates all + * required external actions and events from the root messenger to it. + * + * @param rootMessenger - The root messenger. + * @returns The tracker messenger. + */ +function getMessenger( + rootMessenger: RootMessenger, +): SnapAccountServiceMessenger { + const messenger = new Messenger({ + namespace: 'SnapAccountService', + parent: rootMessenger, + }); + rootMessenger.delegate({ + messenger, + actions: ['SnapController:getSnap', 'SnapController:getRunnableSnaps'], + events: [ + 'SnapController:snapInstalled', + 'SnapController:snapEnabled', + 'SnapController:snapDisabled', + 'SnapController:snapBlocked', + 'SnapController:snapUnblocked', + 'SnapController:snapUninstalled', + ], + }); + return messenger; +} + +/** + * Builds a minimal `TruncatedSnap` for tests. + * + * @param id - The Snap ID. + * @param isKeyring - Whether the Snap declares the `endowment:keyring` initial permission. + * @returns A minimal `TruncatedSnap`. + */ +function buildSnap(id: string, isKeyring: boolean): TruncatedSnap { + return { + id: id as SnapId, + initialPermissions: isKeyring ? { 'endowment:keyring': {} } : {}, + enabled: true, + blocked: false, + } as MockTruncatedSnap as TruncatedSnap; +} + +/** + * Publishes a `SnapController:snapInstalled` event on the root messenger. + * + * @param rootMessenger - The root messenger. + * @param snap - The Snap that was installed. + */ +function publishSnapInstalled( + rootMessenger: RootMessenger, + snap: TruncatedSnap, +): void { + rootMessenger.publish('SnapController:snapInstalled', snap, 'origin', false); +} + +/** + * Publishes a `SnapController:snapEnabled` event on the root messenger. + * + * @param rootMessenger - The root messenger. + * @param snap - The Snap that was enabled. + */ +function publishSnapEnabled( + rootMessenger: RootMessenger, + snap: TruncatedSnap, +): void { + rootMessenger.publish('SnapController:snapEnabled', snap); +} + +/** + * Publishes a `SnapController:snapDisabled` event on the root messenger. + * + * @param rootMessenger - The root messenger. + * @param snap - The Snap that was disabled. + */ +function publishSnapDisabled( + rootMessenger: RootMessenger, + snap: TruncatedSnap, +): void { + rootMessenger.publish('SnapController:snapDisabled', snap); +} + +/** + * Publishes a `SnapController:snapBlocked` event on the root messenger. + * + * @param rootMessenger - The root messenger. + * @param snapId - The ID of the Snap that was blocked. + */ +function publishSnapBlocked( + rootMessenger: RootMessenger, + snapId: string, +): void { + rootMessenger.publish('SnapController:snapBlocked', snapId); +} + +/** + * Publishes a `SnapController:snapUnblocked` event on the root messenger. + * + * @param rootMessenger - The root messenger. + * @param snapId - The ID of the Snap that was unblocked. + */ +function publishSnapUnblocked( + rootMessenger: RootMessenger, + snapId: string, +): void { + rootMessenger.publish('SnapController:snapUnblocked', snapId); +} + +/** + * Publishes a `SnapController:snapUninstalled` event on the root messenger. + * + * @param rootMessenger - The root messenger. + * @param snap - The Snap that was uninstalled. + */ +function publishSnapUninstalled( + rootMessenger: RootMessenger, + snap: TruncatedSnap, +): void { + rootMessenger.publish('SnapController:snapUninstalled', snap); +} + +/** + * Constructs the tracker under test with sensible defaults. + * + * @param args - The arguments to this function. + * @param args.runnableSnaps - Snaps returned by `SnapController:getRunnableSnaps`. + * @returns The new tracker, root messenger, tracker messenger, and mocks. + */ +function setup({ + runnableSnaps = [], +}: { + runnableSnaps?: TruncatedSnap[]; +} = {}): { + tracker: SnapTracker; + rootMessenger: RootMessenger; + messenger: SnapAccountServiceMessenger; + mocks: Mocks; +} { + const rootMessenger = getRootMessenger(); + const messenger = getMessenger(rootMessenger); + + const mocks: Mocks = { + SnapController: { + getSnap: jest.fn().mockReturnValue(null), + getRunnableSnaps: jest.fn().mockReturnValue(runnableSnaps), + }, + }; + + rootMessenger.registerActionHandler( + 'SnapController:getSnap', + mocks.SnapController.getSnap as never, + ); + rootMessenger.registerActionHandler( + 'SnapController:getRunnableSnaps', + mocks.SnapController.getRunnableSnaps, + ); + + const tracker = new SnapTracker(messenger); + + return { tracker, rootMessenger, messenger, mocks }; +} + +const MOCK_SNAP_ID = 'npm:@metamask/mock-snap' as SnapId; +const MOCK_OTHER_SNAP_ID = 'npm:@metamask/other-snap' as SnapId; + +describe('SnapTracker', () => { + describe('init', () => { + it('resolves without throwing', async () => { + const { tracker } = setup(); + + expect(await tracker.init()).toBeUndefined(); + }); + + it('does not re-init if already initialized', async () => { + const { tracker, mocks } = setup(); + + expect(await tracker.init()).toBeUndefined(); + expect(mocks.SnapController.getRunnableSnaps).toHaveBeenCalledTimes(1); + + expect(await tracker.init()).toBeUndefined(); + expect(mocks.SnapController.getRunnableSnaps).toHaveBeenCalledTimes(1); // Still only called once. + }); + + it('seeds tracked Snaps from getRunnableSnaps, filtering out non-keyring Snaps', async () => { + const { tracker } = setup({ + runnableSnaps: [ + buildSnap(MOCK_SNAP_ID, true), + buildSnap(MOCK_OTHER_SNAP_ID, false), + ], + }); + + await tracker.init(); + + expect(tracker.getSnaps()).toStrictEqual([MOCK_SNAP_ID]); + }); + }); + + describe('getSnaps', () => { + it('returns an empty array before init', () => { + const { tracker } = setup({ + runnableSnaps: [buildSnap(MOCK_SNAP_ID, true)], + }); + + expect(tracker.getSnaps()).toStrictEqual([]); + }); + }); + + describe('canUse', () => { + it('returns false before init', () => { + const { tracker } = setup({ + runnableSnaps: [buildSnap(MOCK_SNAP_ID, true)], + }); + + expect(tracker.canUse(MOCK_SNAP_ID)).toBe(false); + }); + + it('returns true for a tracked Snap', async () => { + const { tracker } = setup({ + runnableSnaps: [buildSnap(MOCK_SNAP_ID, true)], + }); + + await tracker.init(); + + expect(tracker.canUse(MOCK_SNAP_ID)).toBe(true); + }); + + it('returns false for an untracked Snap', async () => { + const { tracker } = setup(); + + await tracker.init(); + + expect(tracker.canUse(MOCK_SNAP_ID)).toBe(false); + }); + }); + + describe('lifecycle events', () => { + it('ignores add events received before init', async () => { + const { tracker, rootMessenger } = setup(); + + publishSnapInstalled(rootMessenger, buildSnap(MOCK_SNAP_ID, true)); + + await tracker.init(); + + expect(tracker.getSnaps()).toStrictEqual([]); + }); + + it('ignores remove events received before init', async () => { + const { tracker, rootMessenger } = setup({ + runnableSnaps: [buildSnap(MOCK_SNAP_ID, true)], + }); + + publishSnapUninstalled(rootMessenger, buildSnap(MOCK_SNAP_ID, true)); + + await tracker.init(); + + expect(tracker.getSnaps()).toStrictEqual([MOCK_SNAP_ID]); + }); + + it('adds a Snap on snapInstalled when it has the keyring endowment', async () => { + const { tracker, rootMessenger } = setup(); + + await tracker.init(); + publishSnapInstalled(rootMessenger, buildSnap(MOCK_SNAP_ID, true)); + + expect(tracker.getSnaps()).toStrictEqual([MOCK_SNAP_ID]); + }); + + it('does not add a Snap on snapInstalled when it lacks the keyring endowment', async () => { + const { tracker, rootMessenger } = setup(); + + await tracker.init(); + publishSnapInstalled(rootMessenger, buildSnap(MOCK_SNAP_ID, false)); + + expect(tracker.getSnaps()).toStrictEqual([]); + }); + + it('adds a Snap on snapEnabled when it has the keyring endowment', async () => { + const { tracker, rootMessenger } = setup(); + + await tracker.init(); + publishSnapEnabled(rootMessenger, buildSnap(MOCK_SNAP_ID, true)); + + expect(tracker.getSnaps()).toStrictEqual([MOCK_SNAP_ID]); + }); + + it('removes a Snap on snapDisabled', async () => { + const { tracker, rootMessenger } = setup({ + runnableSnaps: [buildSnap(MOCK_SNAP_ID, true)], + }); + + await tracker.init(); + expect(tracker.getSnaps()).toStrictEqual([MOCK_SNAP_ID]); + + publishSnapDisabled(rootMessenger, buildSnap(MOCK_SNAP_ID, true)); + + expect(tracker.getSnaps()).toStrictEqual([]); + }); + + it('removes a Snap on snapBlocked', async () => { + const { tracker, rootMessenger } = setup({ + runnableSnaps: [buildSnap(MOCK_SNAP_ID, true)], + }); + + await tracker.init(); + publishSnapBlocked(rootMessenger, MOCK_SNAP_ID); + + expect(tracker.getSnaps()).toStrictEqual([]); + }); + + it('ignores snapUnblocked received before init', async () => { + const { tracker, rootMessenger, mocks } = setup(); + + mocks.SnapController.getSnap.mockReturnValue({ + ...buildSnap(MOCK_SNAP_ID, true), + enabled: true, + blocked: false, + } as TruncatedSnap); + publishSnapUnblocked(rootMessenger, MOCK_SNAP_ID); + + await tracker.init(); + + expect(tracker.getSnaps()).toStrictEqual([]); + }); + + it('re-adds a Snap on snapUnblocked when it is enabled and has the keyring endowment', async () => { + const { tracker, rootMessenger, mocks } = setup(); + + await tracker.init(); + mocks.SnapController.getSnap.mockReturnValue({ + ...buildSnap(MOCK_SNAP_ID, true), + enabled: true, + blocked: false, + } as TruncatedSnap); + + publishSnapUnblocked(rootMessenger, MOCK_SNAP_ID); + + expect(tracker.getSnaps()).toStrictEqual([MOCK_SNAP_ID]); + }); + + it('does not re-add a Snap on snapUnblocked when it is disabled', async () => { + const { tracker, rootMessenger, mocks } = setup(); + + await tracker.init(); + mocks.SnapController.getSnap.mockReturnValue({ + ...buildSnap(MOCK_SNAP_ID, true), + enabled: false, + blocked: false, + } as TruncatedSnap); + + publishSnapUnblocked(rootMessenger, MOCK_SNAP_ID); + + expect(tracker.getSnaps()).toStrictEqual([]); + }); + + it('does not re-add a Snap on snapUnblocked when it lacks the keyring endowment', async () => { + const { tracker, rootMessenger, mocks } = setup(); + + await tracker.init(); + mocks.SnapController.getSnap.mockReturnValue({ + ...buildSnap(MOCK_SNAP_ID, false), + enabled: true, + blocked: false, + } as TruncatedSnap); + + publishSnapUnblocked(rootMessenger, MOCK_SNAP_ID); + + expect(tracker.getSnaps()).toStrictEqual([]); + }); + + it('does not re-add a Snap on snapUnblocked when getSnap returns null', async () => { + const { tracker, rootMessenger } = setup(); + + await tracker.init(); + publishSnapUnblocked(rootMessenger, MOCK_SNAP_ID); + + expect(tracker.getSnaps()).toStrictEqual([]); + }); + + it('removes a Snap on snapUninstalled', async () => { + const { tracker, rootMessenger } = setup({ + runnableSnaps: [buildSnap(MOCK_SNAP_ID, true)], + }); + + await tracker.init(); + publishSnapUninstalled(rootMessenger, buildSnap(MOCK_SNAP_ID, true)); + + expect(tracker.getSnaps()).toStrictEqual([]); + }); + }); +}); diff --git a/packages/snap-account-service/src/SnapTracker.ts b/packages/snap-account-service/src/SnapTracker.ts new file mode 100644 index 0000000000..4bd13e7b28 --- /dev/null +++ b/packages/snap-account-service/src/SnapTracker.ts @@ -0,0 +1,155 @@ +import { SnapId } from '@metamask/snaps-sdk'; +import type { TruncatedSnap } from '@metamask/snaps-utils'; + +import { projectLogger as log } from './logger'; +import type { SnapAccountServiceMessenger } from './SnapAccountService'; + +/** + * Checks if a given Snap is an account management Snap. + * + * @param snap - The Snap to check. + * @returns True if the Snap declares the `endowment:keyring` initial + * permission. + */ +function isAccountManagementSnap(snap: TruncatedSnap): boolean { + return snap.initialPermissions['endowment:keyring'] !== undefined; +} + +/** + * Tracks the set of installed, enabled, non-blocked account-management Snaps + * (Snaps declaring the `endowment:keyring` initial permission) by listening to + * `SnapController` lifecycle events. + */ +export class SnapTracker { + readonly #messenger: SnapAccountServiceMessenger; + + readonly #snaps: Set = new Set(); + + #initialized = false; + + constructor(messenger: SnapAccountServiceMessenger) { + this.#messenger = messenger; + + this.#messenger.subscribe('SnapController:snapInstalled', (snap) => + this.#handleSnapAdded(snap, 'installed'), + ); + this.#messenger.subscribe('SnapController:snapUninstalled', (snap) => + this.#handleSnapRemoved(snap.id, 'uninstalled'), + ); + this.#messenger.subscribe('SnapController:snapEnabled', (snap) => + this.#handleSnapAdded(snap, 'enabled'), + ); + this.#messenger.subscribe('SnapController:snapDisabled', (snap) => + this.#handleSnapRemoved(snap.id, 'disabled'), + ); + this.#messenger.subscribe('SnapController:snapBlocked', (snapId) => + this.#handleSnapRemoved(snapId as SnapId, 'blocked'), + ); + this.#messenger.subscribe('SnapController:snapUnblocked', (snapId) => + this.#handleSnapUnblocked(snapId as SnapId), + ); + } + + /** + * Seeds the internal set of account-management Snaps from + * `SnapController:getRunnableSnaps`, then starts processing lifecycle + * events. + */ + async init(): Promise { + if (this.#initialized) { + // Do not re-init, once setup we only rely on lifecycle events to update the set of + // tracked Snaps. + return; + } + + this.#snaps.clear(); + + const runnable = this.#messenger.call('SnapController:getRunnableSnaps'); + for (const snap of runnable) { + if (isAccountManagementSnap(snap)) { + log(`Found account management Snap: ${snap.id} (initialization)`); + this.#snaps.add(snap.id); + } + } + + this.#initialized = true; + } + + /** + * Returns the IDs of all currently tracked account-management Snaps. + * + * @returns The IDs of tracked account-management Snaps. + */ + getSnaps(): SnapId[] { + return [...this.#snaps]; + } + + /** + * Returns true if the given Snap ID is currently tracked and can be used. + * + * @param snapId - The Snap ID to check. + * @returns True if the Snap is tracked and can be used. + */ + canUse(snapId: SnapId): boolean { + return this.#snaps.has(snapId); + } + + /** + * Handles a Snap being added (installed or enabled). If the Snap is an + * account-management Snap, adds it to the internal set of tracked Snaps. + * + * @param snap - The Snap that was installed or enabled. + * @param reason - The reason the Snap was added. + */ + #handleSnapAdded(snap: TruncatedSnap, reason: string): void { + if (!this.#initialized) { + return; + } + + if (!snap.enabled || snap.blocked) { + return; + } + + if (isAccountManagementSnap(snap) && !this.#snaps.has(snap.id)) { + log(`Added account management Snap: ${snap.id} (${reason})`); + + this.#snaps.add(snap.id); + } + } + + /** + * Handles a Snap being unblocked. If the Snap is an enabled + * account-management Snap, re-adds it to the internal set of tracked Snaps. + * + * @param snapId - The Snap ID that was unblocked. + */ + #handleSnapUnblocked(snapId: SnapId): void { + if (!this.#initialized) { + return; + } + + const snap = this.#messenger.call('SnapController:getSnap', snapId); + if (snap) { + this.#handleSnapAdded(snap, 'unblocked'); + } + } + + /** + * Handles a Snap being removed (disabled, blocked, or uninstalled). If the Snap is an + * account-management Snap, removes it from the internal set of tracked Snaps. + * + * @param snapId - The Snap ID that was disabled, blocked, or uninstalled. + * @param reason - The reason the Snap was removed. + */ + #handleSnapRemoved(snapId: SnapId, reason: string): void { + if (!this.#initialized) { + return; + } + + if (this.#snaps.has(snapId)) { + log(`Removed account management Snap: ${snapId} (${reason})`); + + this.#snaps.delete(snapId); + } + } +} diff --git a/packages/snap-account-service/src/index.ts b/packages/snap-account-service/src/index.ts index 557b5fdf99..c013baa23a 100644 --- a/packages/snap-account-service/src/index.ts +++ b/packages/snap-account-service/src/index.ts @@ -6,6 +6,9 @@ export type { SnapAccountServiceMessenger, SnapAccountServiceOptions, } from './SnapAccountService'; -export type { SnapAccountServiceEnsureReadyAction } from './SnapAccountService-method-action-types'; +export type { + SnapAccountServiceEnsureReadyAction, + SnapAccountServiceGetSnapsAction, +} from './SnapAccountService-method-action-types'; export { SnapPlatformWatcher } from './SnapPlatformWatcher'; export type { SnapPlatformWatcherConfig } from './SnapPlatformWatcher';