Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions packages/snap-account-service/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: "<id>"` 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/
Original file line number Diff line number Diff line change
Expand Up @@ -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`;
Expand All @@ -22,4 +36,5 @@ export type SnapAccountServiceEnsureReadyAction = {
* Union of all SnapAccountService action types.
*/
export type SnapAccountServiceMethodActions =
SnapAccountServiceEnsureReadyAction;
| SnapAccountServiceGetSnapsAction
| SnapAccountServiceEnsureReadyAction;
129 changes: 113 additions & 16 deletions packages/snap-account-service/src/SnapAccountService.test.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,43 @@
import { KeyringTypes } from '@metamask/keyring-controller';
import {
KeyringControllerState,
KeyringTypes,
} from '@metamask/keyring-controller';
import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger';
import type {
MockAnyNamespace,
MessengerActions,
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,
SnapAccountServiceOptions,
} 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<SnapAccountServiceMessenger>,
MessengerEvents<SnapAccountServiceMessenger>
>;

/**
* Mock objects for all external dependencies of {@link SnapAccountService}.
*/
/** Mock keyring controller state type for tests. */
type MockKeyringControllerState = Pick<KeyringControllerState, 'keyrings'>;

/** 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: {
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
Expand All @@ -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 }),
Expand All @@ -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,
Expand All @@ -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', () => {
Expand All @@ -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(() => {
Expand All @@ -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(() => {
Expand All @@ -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.
Expand All @@ -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;
Expand Down
Loading
Loading