diff --git a/packages/examples/packages/lifecycle-hooks/README.md b/packages/examples/packages/lifecycle-hooks/README.md
index a740b83472..58e1631705 100644
--- a/packages/examples/packages/lifecycle-hooks/README.md
+++ b/packages/examples/packages/lifecycle-hooks/README.md
@@ -1,7 +1,7 @@
# `@metamask/lifecylce-hooks-example-snap`
-This snap demonstrates how to use the `onInstall` and `onUpdate` lifecycle
-hooks.
+This Snap demonstrates how to use the `onStart`, `onInstall`, and `onUpdate`
+lifecycle hooks.
## Snap manifest
@@ -22,9 +22,9 @@ Along with other permissions, the manifest of this snap includes the
## Snap usage
-This snap exposes the `onInstall` and `onUpdate` lifecycle hooks. These hooks
-are called when the snap is installed or updated, respectively, and cannot be
-called manually.
+This Snap exposes the `onStart`, `onInstall`, and `onUpdate` lifecycle hooks.
+These hooks are called when the client is started, when the Snap is installed,
+or the Snap is updated, respectively, and cannot be called manually.
For more information, you can refer to
[the end-to-end tests](./src/index.test.ts).
diff --git a/packages/examples/packages/lifecycle-hooks/package.json b/packages/examples/packages/lifecycle-hooks/package.json
index fd12cf32bc..ca5e47f023 100644
--- a/packages/examples/packages/lifecycle-hooks/package.json
+++ b/packages/examples/packages/lifecycle-hooks/package.json
@@ -1,7 +1,7 @@
{
"name": "@metamask/lifecycle-hooks-example-snap",
"version": "2.1.3",
- "description": "MetaMask example snap demonstrating the use of the `onInstall` and `onUpdate` lifecycle hooks",
+ "description": "MetaMask example snap demonstrating the use of the `onStart`, `onInstall`, and `onUpdate` lifecycle hooks",
"keywords": [
"MetaMask",
"Snaps",
diff --git a/packages/examples/packages/lifecycle-hooks/snap.manifest.json b/packages/examples/packages/lifecycle-hooks/snap.manifest.json
index 64db943321..6183e6b835 100644
--- a/packages/examples/packages/lifecycle-hooks/snap.manifest.json
+++ b/packages/examples/packages/lifecycle-hooks/snap.manifest.json
@@ -1,13 +1,13 @@
{
"version": "2.1.3",
- "description": "MetaMask example snap demonstrating the use of the `onInstall` and `onUpdate` lifecycle hooks.",
+ "description": "MetaMask example snap demonstrating the use of the `onStart`, `onInstall`, and `onUpdate` lifecycle hooks.",
"proposedName": "Lifecycle Hooks Example Snap",
"repository": {
"type": "git",
"url": "https://github.com/MetaMask/snaps.git"
},
"source": {
- "shasum": "q+mPr3Gq/2PZDIrqKu+wR7sh1hwKk+F5OoEo1ye+30Q=",
+ "shasum": "c6HavArVNGdBQlFYdVeKXpahvU0DHpB6D6UupoexRR0=",
"location": {
"npm": {
"filePath": "dist/bundle.js",
diff --git a/packages/examples/packages/lifecycle-hooks/src/index.test.tsx b/packages/examples/packages/lifecycle-hooks/src/index.test.tsx
index 47433b59ab..639a3c0460 100644
--- a/packages/examples/packages/lifecycle-hooks/src/index.test.tsx
+++ b/packages/examples/packages/lifecycle-hooks/src/index.test.tsx
@@ -2,6 +2,30 @@ import { describe, expect, it } from '@jest/globals';
import { assertIsAlertDialog, installSnap } from '@metamask/snaps-jest';
import { Box, Text } from '@metamask/snaps-sdk/jsx';
+describe('onStart', () => {
+ it('shows dialog when the client is started', async () => {
+ const { onStart } = await installSnap();
+
+ const response = onStart();
+
+ const screen = await response.getInterface();
+ assertIsAlertDialog(screen);
+
+ expect(screen).toRender(
+
+
+ The client was started successfully, and the "onStart" handler was
+ called.
+
+ ,
+ );
+
+ await screen.ok();
+
+ expect(await response).toRespondWith(null);
+ });
+});
+
describe('onInstall', () => {
it('shows dialog when the snap is installed', async () => {
const { onInstall } = await installSnap();
diff --git a/packages/examples/packages/lifecycle-hooks/src/index.tsx b/packages/examples/packages/lifecycle-hooks/src/index.tsx
index b429cc701a..2051992a03 100644
--- a/packages/examples/packages/lifecycle-hooks/src/index.tsx
+++ b/packages/examples/packages/lifecycle-hooks/src/index.tsx
@@ -1,6 +1,37 @@
-import type { OnInstallHandler, OnUpdateHandler } from '@metamask/snaps-sdk';
+import type {
+ OnInstallHandler,
+ OnStartHandler,
+ OnUpdateHandler,
+} from '@metamask/snaps-sdk';
import { Box, Text } from '@metamask/snaps-sdk/jsx';
+/**
+ * Handle starting of the client. This handler is called when the client is
+ * started, and can be used to perform any initialization that is required.
+ *
+ * This handler is optional. If it is not provided, the Snap will be started
+ * as usual.
+ *
+ * @see https://docs.metamask.io/snaps/reference/entry-points/#onstart
+ * @returns The JSON-RPC response.
+ */
+export const onStart: OnStartHandler = async () => {
+ return await snap.request({
+ method: 'snap_dialog',
+ params: {
+ type: 'alert',
+ content: (
+
+
+ The client was started successfully, and the "onStart" handler was
+ called.
+
+
+ ),
+ },
+ });
+};
+
/**
* Handle installation of the snap. This handler is called when the snap is
* installed, and can be used to perform any initialization that is required.'
@@ -8,7 +39,7 @@ import { Box, Text } from '@metamask/snaps-sdk/jsx';
* This handler is optional. If it is not provided, the snap will be installed
* as usual.
*
- * @see https://docs.metamask.io/snaps/reference/exports/#oninstall
+ * @see https://docs.metamask.io/snaps/reference/entry-points/#oninstall
* @returns The JSON-RPC response.
*/
export const onInstall: OnInstallHandler = async () => {
@@ -35,7 +66,7 @@ export const onInstall: OnInstallHandler = async () => {
* This handler is optional. If it is not provided, the snap will be updated
* as usual.
*
- * @see https://docs.metamask.io/snaps/reference/exports/#onupdate
+ * @see https://docs.metamask.io/snaps/reference/entry-points/#onupdate
* @returns The JSON-RPC response.
*/
export const onUpdate: OnUpdateHandler = async () => {
diff --git a/packages/snaps-controllers/coverage.json b/packages/snaps-controllers/coverage.json
index 287f895bda..6d89f22e62 100644
--- a/packages/snaps-controllers/coverage.json
+++ b/packages/snaps-controllers/coverage.json
@@ -1,6 +1,6 @@
{
- "branches": 95.21,
- "functions": 98.71,
+ "branches": 95.22,
+ "functions": 98.72,
"lines": 98.87,
- "statements": 98.7
+ "statements": 98.71
}
diff --git a/packages/snaps-controllers/src/snaps/SnapController.test.tsx b/packages/snaps-controllers/src/snaps/SnapController.test.tsx
index 9d13b59bef..2a031d830d 100644
--- a/packages/snaps-controllers/src/snaps/SnapController.test.tsx
+++ b/packages/snaps-controllers/src/snaps/SnapController.test.tsx
@@ -9889,6 +9889,127 @@ describe('SnapController', () => {
});
describe('SnapController actions', () => {
+ describe('SnapController:init', () => {
+ it('calls `onStart` for all Snaps with the `endowment:lifecycle-hooks` permission', async () => {
+ const rootMessenger = getControllerMessenger();
+ const messenger = getSnapControllerMessenger(rootMessenger);
+
+ rootMessenger.registerActionHandler(
+ 'PermissionController:getPermissions',
+ (origin) => {
+ if (origin === MOCK_SNAP_ID) {
+ return {
+ [SnapEndowments.LifecycleHooks]:
+ MOCK_LIFECYCLE_HOOKS_PERMISSION,
+ };
+ }
+
+ return {};
+ },
+ );
+
+ rootMessenger.registerActionHandler(
+ 'PermissionController:hasPermission',
+ (origin) => {
+ return origin === MOCK_SNAP_ID;
+ },
+ );
+
+ const snapController = getSnapController(
+ getSnapControllerOptions({
+ messenger,
+ state: {
+ snaps: getPersistedSnapsState(
+ getPersistedSnapObject({
+ id: MOCK_SNAP_ID,
+ }),
+ getPersistedSnapObject({
+ id: MOCK_LOCAL_SNAP_ID,
+ }),
+ ),
+ },
+ }),
+ );
+
+ const call = jest.spyOn(messenger, 'call');
+ messenger.call('SnapController:init');
+ await sleep(10);
+
+ expect(call).toHaveBeenNthCalledWith(
+ 2,
+ 'PermissionController:hasPermission',
+ MOCK_SNAP_ID,
+ 'endowment:lifecycle-hooks',
+ );
+
+ expect(call).toHaveBeenNthCalledWith(
+ 6,
+ 'ExecutionService:executeSnap',
+ expect.any(Object),
+ );
+
+ expect(messenger.call).toHaveBeenNthCalledWith(
+ 7,
+ 'ExecutionService:handleRpcRequest',
+ MOCK_SNAP_ID,
+ {
+ handler: HandlerType.OnStart,
+ origin: METAMASK_ORIGIN,
+ request: {
+ jsonrpc: '2.0',
+ id: expect.any(String),
+ method: HandlerType.OnStart,
+ },
+ },
+ );
+
+ snapController.destroy();
+ });
+
+ it('logs an error if the lifecycle hook throws', async () => {
+ const consoleErrorSpy = jest
+ .spyOn(console, 'error')
+ .mockImplementation();
+
+ const rootMessenger = getControllerMessenger();
+ const messenger = getSnapControllerMessenger(rootMessenger);
+
+ rootMessenger.registerActionHandler(
+ 'PermissionController:getPermissions',
+ () => {
+ return {
+ [SnapEndowments.LifecycleHooks]: MOCK_LIFECYCLE_HOOKS_PERMISSION,
+ };
+ },
+ );
+
+ rootMessenger.registerActionHandler(
+ 'ExecutionService:handleRpcRequest',
+ () => {
+ throw new Error('Test error in lifecycle hook.');
+ },
+ );
+
+ const snapController = getSnapController(
+ getSnapControllerOptions({
+ messenger,
+ state: {
+ snaps: getPersistedSnapsState(),
+ },
+ }),
+ );
+
+ messenger.call('SnapController:init');
+ await sleep(10);
+
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ `Error when calling \`onStart\` lifecycle hook for Snap "npm:@metamask/example-snap": Test error in lifecycle hook.`,
+ );
+
+ snapController.destroy();
+ });
+ });
+
describe('SnapController:get', () => {
it('gets a snap', () => {
const messenger = getSnapControllerMessenger();
diff --git a/packages/snaps-controllers/src/snaps/SnapController.ts b/packages/snaps-controllers/src/snaps/SnapController.ts
index f0ac1fc1a4..a9ceec5ac4 100644
--- a/packages/snaps-controllers/src/snaps/SnapController.ts
+++ b/packages/snaps-controllers/src/snaps/SnapController.ts
@@ -338,6 +338,15 @@ type PendingApproval = {
// Controller Messenger Actions
+/**
+ * Initialise the SnapController. This should be called after all controllers
+ * are created.
+ */
+export type SnapControllerInitAction = {
+ type: `${typeof controllerName}:init`;
+ handler: SnapController['init'];
+};
+
/**
* Gets the specified Snap from state.
*/
@@ -470,6 +479,7 @@ export type SnapControllerGetStateAction = ControllerGetStateAction<
>;
export type SnapControllerActions =
+ | SnapControllerInitAction
| ClearSnapState
| GetSnap
| GetSnapState
@@ -1160,6 +1170,11 @@ export class SnapController extends BaseController<
* actions.
*/
#registerMessageHandlers(): void {
+ this.messagingSystem.registerActionHandler(
+ `${controllerName}:init`,
+ (...args) => this.init(...args),
+ );
+
this.messagingSystem.registerActionHandler(
`${controllerName}:clearSnapState`,
(...args) => this.clearSnapState(...args),
@@ -1266,6 +1281,37 @@ export class SnapController extends BaseController<
);
}
+ /**
+ * Initialise the SnapController.
+ *
+ * Currently this method calls the `onStart` lifecycle hook for all
+ * installed Snaps.
+ */
+ init() {
+ const snaps = this.getRunnableSnaps();
+ for (const { id } of snaps) {
+ const hasLifecycleHooksEndowment = this.messagingSystem.call(
+ 'PermissionController:hasPermission',
+ id,
+ SnapEndowments.LifecycleHooks,
+ );
+
+ if (!hasLifecycleHooksEndowment) {
+ continue;
+ }
+
+ this.#callLifecycleHook(METAMASK_ORIGIN, id, HandlerType.OnStart).catch(
+ (error) => {
+ logError(
+ `Error when calling \`onStart\` lifecycle hook for Snap "${id}": ${getErrorMessage(
+ error,
+ )}`,
+ );
+ },
+ );
+ }
+ }
+
#handlePreinstalledSnaps(preinstalledSnaps: PreinstalledSnap[]) {
for (const {
snapId,
diff --git a/packages/snaps-execution-environments/coverage.json b/packages/snaps-execution-environments/coverage.json
index bc6a469604..e4e7ddfae4 100644
--- a/packages/snaps-execution-environments/coverage.json
+++ b/packages/snaps-execution-environments/coverage.json
@@ -1,6 +1,6 @@
{
- "branches": 90,
+ "branches": 90.04,
"functions": 94.69,
- "lines": 90.41,
+ "lines": 90.42,
"statements": 89.62
}
diff --git a/packages/snaps-execution-environments/src/common/BaseSnapExecutor.test.browser.ts b/packages/snaps-execution-environments/src/common/BaseSnapExecutor.test.browser.ts
index 32d17c0af1..b81728db3b 100644
--- a/packages/snaps-execution-environments/src/common/BaseSnapExecutor.test.browser.ts
+++ b/packages/snaps-execution-environments/src/common/BaseSnapExecutor.test.browser.ts
@@ -1896,7 +1896,11 @@ describe('BaseSnapExecutor', () => {
});
describe('lifecycle hooks', () => {
- const LIFECYCLE_HOOKS = [HandlerType.OnInstall, HandlerType.OnUpdate];
+ const LIFECYCLE_HOOKS = [
+ HandlerType.OnInstall,
+ HandlerType.OnUpdate,
+ HandlerType.OnStart,
+ ];
for (const handler of LIFECYCLE_HOOKS) {
it(`supports \`${handler}\` export`, async () => {
diff --git a/packages/snaps-execution-environments/src/common/commands.ts b/packages/snaps-execution-environments/src/common/commands.ts
index 68588a43f6..bc1c574248 100644
--- a/packages/snaps-execution-environments/src/common/commands.ts
+++ b/packages/snaps-execution-environments/src/common/commands.ts
@@ -120,6 +120,7 @@ export function getHandlerArguments(
case HandlerType.OnInstall:
case HandlerType.OnUpdate:
+ case HandlerType.OnStart:
return { origin };
case HandlerType.OnHomePage:
diff --git a/packages/snaps-jest/src/helpers.ts b/packages/snaps-jest/src/helpers.ts
index 2300147be5..42d8cfe27c 100644
--- a/packages/snaps-jest/src/helpers.ts
+++ b/packages/snaps-jest/src/helpers.ts
@@ -187,6 +187,7 @@ export async function installSnap<
onKeyringRequest,
onInstall,
onUpdate,
+ onStart,
onNameLookup,
onProtocolRequest,
onClientRequest,
@@ -208,6 +209,7 @@ export async function installSnap<
onKeyringRequest,
onInstall,
onUpdate,
+ onStart,
onNameLookup,
onProtocolRequest,
onClientRequest,
diff --git a/packages/snaps-rpc-methods/src/endowments/index.ts b/packages/snaps-rpc-methods/src/endowments/index.ts
index 5730bea959..c656c73a9e 100644
--- a/packages/snaps-rpc-methods/src/endowments/index.ts
+++ b/packages/snaps-rpc-methods/src/endowments/index.ts
@@ -121,6 +121,7 @@ export const handlerEndowments: Record = {
[HandlerType.OnNameLookup]: nameLookupEndowmentBuilder.targetName,
[HandlerType.OnInstall]: lifecycleHooksEndowmentBuilder.targetName,
[HandlerType.OnUpdate]: lifecycleHooksEndowmentBuilder.targetName,
+ [HandlerType.OnStart]: lifecycleHooksEndowmentBuilder.targetName,
[HandlerType.OnKeyringRequest]: keyringEndowmentBuilder.targetName,
[HandlerType.OnHomePage]: homePageEndowmentBuilder.targetName,
[HandlerType.OnSettingsPage]: settingsPageEndowmentBuilder.targetName,
diff --git a/packages/snaps-sdk/src/types/handlers/lifecycle.ts b/packages/snaps-sdk/src/types/handlers/lifecycle.ts
index 80585a3fc5..f047bd013b 100644
--- a/packages/snaps-sdk/src/types/handlers/lifecycle.ts
+++ b/packages/snaps-sdk/src/types/handlers/lifecycle.ts
@@ -37,3 +37,16 @@ export type OnInstallHandler = LifecycleEventHandler;
* @param args.origin - The origin that triggered the lifecycle event hook.
*/
export type OnUpdateHandler = LifecycleEventHandler;
+
+/**
+ * The `onStart` handler. This is called when the client is started.
+ *
+ * Note that using this handler requires the `endowment:lifecycle-hooks`
+ * permission.
+ *
+ * This type is an alias for {@link LifecycleEventHandler}.
+ *
+ * @param args - The request arguments.
+ * @param args.origin - The origin that triggered the lifecycle event hook.
+ */
+export type OnStartHandler = LifecycleEventHandler;
diff --git a/packages/snaps-simulation/src/helpers.test.tsx b/packages/snaps-simulation/src/helpers.test.tsx
index 74dcb839ca..7a88596093 100644
--- a/packages/snaps-simulation/src/helpers.test.tsx
+++ b/packages/snaps-simulation/src/helpers.test.tsx
@@ -701,6 +701,32 @@ describe('helpers', () => {
});
});
+ describe('onStart', () => {
+ it('sends a onStart request and returns the result', async () => {
+ jest.spyOn(console, 'log').mockImplementation();
+
+ const { snapId, close: closeServer } = await getMockServer({
+ sourceCode: `
+ module.exports.onStart = async () => {
+ return { content: { type: 'text', value: 'Hello, world!' } };
+ };
+ `,
+ });
+
+ const { onStart, close } = await installSnap(snapId);
+ const response = await onStart();
+
+ expect(response).toStrictEqual(
+ expect.objectContaining({
+ getInterface: expect.any(Function),
+ }),
+ );
+
+ await close();
+ await closeServer();
+ });
+ });
+
describe('onProtocolRequest', () => {
it('sends a onProtocolRequest request and returns the result', async () => {
jest.spyOn(console, 'log').mockImplementation();
diff --git a/packages/snaps-simulation/src/helpers.ts b/packages/snaps-simulation/src/helpers.ts
index 889e2ba3f8..fc976ff60c 100644
--- a/packages/snaps-simulation/src/helpers.ts
+++ b/packages/snaps-simulation/src/helpers.ts
@@ -158,6 +158,13 @@ export type SnapHelpers = {
*/
onUpdate(request?: Pick): SnapRequest;
+ /**
+ * Get the response from the Snap's `onStart` handler.
+ *
+ * @returns The response.
+ */
+ onStart(request?: Pick): SnapRequest;
+
/**
* Get the response from the Snap's `onNameLookup` handler.
*
@@ -368,6 +375,25 @@ export function getHelpers({
});
},
+ // This can't be async because it returns a `SnapRequest`.
+ // eslint-disable-next-line @typescript-eslint/promise-function-async
+ onStart: (request?: Pick) => {
+ log('Running onStart handler.');
+
+ return handleRequest({
+ snapId,
+ store,
+ executionService,
+ controllerMessenger,
+ runSaga,
+ handler: HandlerType.OnStart,
+ request: {
+ method: '',
+ ...request,
+ },
+ });
+ },
+
onNameLookup: async (
nameLookupOptions: NameLookupOptions,
): Promise => {
diff --git a/packages/snaps-simulation/src/types.ts b/packages/snaps-simulation/src/types.ts
index 37aa907730..73dec241bb 100644
--- a/packages/snaps-simulation/src/types.ts
+++ b/packages/snaps-simulation/src/types.ts
@@ -492,6 +492,13 @@ export type Snap = {
*/
onUpdate(request?: Pick): SnapRequest;
+ /**
+ * Get the response from the Snap's `onStart` handler.
+ *
+ * @returns The response.
+ */
+ onStart(request?: Pick): SnapRequest;
+
/**
* Get the response from the Snap's `onNameLookup` handler.
*
diff --git a/packages/snaps-utils/coverage.json b/packages/snaps-utils/coverage.json
index e800862e07..5eee8de081 100644
--- a/packages/snaps-utils/coverage.json
+++ b/packages/snaps-utils/coverage.json
@@ -1,6 +1,6 @@
{
"branches": 99.76,
- "functions": 99,
+ "functions": 99.01,
"lines": 98.69,
"statements": 97.29
}
diff --git a/packages/snaps-utils/src/handlers/exports.test.ts b/packages/snaps-utils/src/handlers/exports.test.ts
index 8a0b081846..a89da358b8 100644
--- a/packages/snaps-utils/src/handlers/exports.test.ts
+++ b/packages/snaps-utils/src/handlers/exports.test.ts
@@ -21,6 +21,7 @@ describe('SNAP_EXPORT_NAMES', () => {
'onCronjob',
'onInstall',
'onUpdate',
+ 'onStart',
'onNameLookup',
'onKeyringRequest',
'onHomePage',
diff --git a/packages/snaps-utils/src/handlers/exports.ts b/packages/snaps-utils/src/handlers/exports.ts
index 656e79d3de..82aaf19de1 100644
--- a/packages/snaps-utils/src/handlers/exports.ts
+++ b/packages/snaps-utils/src/handlers/exports.ts
@@ -12,6 +12,7 @@ import type {
OnRpcRequestHandler,
OnSettingsPageHandler,
OnSignatureHandler,
+ OnStartHandler,
OnTransactionHandler,
OnUpdateHandler,
OnUserInputHandler,
@@ -63,6 +64,13 @@ export const SNAP_EXPORTS = {
return typeof snapExport === 'function';
},
},
+ [HandlerType.OnStart]: {
+ type: HandlerType.OnStart,
+ required: false,
+ validator: (snapExport: unknown): snapExport is OnStartHandler => {
+ return typeof snapExport === 'function';
+ },
+ },
[HandlerType.OnKeyringRequest]: {
type: HandlerType.OnKeyringRequest,
required: true,
diff --git a/packages/snaps-utils/src/handlers/types.ts b/packages/snaps-utils/src/handlers/types.ts
index 371e7932a8..af3ad495f8 100644
--- a/packages/snaps-utils/src/handlers/types.ts
+++ b/packages/snaps-utils/src/handlers/types.ts
@@ -35,6 +35,7 @@ export enum HandlerType {
OnCronjob = 'onCronjob',
OnInstall = 'onInstall',
OnUpdate = 'onUpdate',
+ OnStart = 'onStart',
OnNameLookup = 'onNameLookup',
OnKeyringRequest = 'onKeyringRequest',
OnHomePage = 'onHomePage',