From 8b2bada53b44f129357f423e7a782bf8360a335f Mon Sep 17 00:00:00 2001 From: Hassan Malik <41640681+hmalik88@users.noreply.github.com> Date: Thu, 19 Dec 2024 09:34:52 -0500 Subject: [PATCH] Feat: Add background events (#2941) This PR adds a new feature: background events, per [SIP-28](https://github.com/MetaMask/SIPs/blob/main/SIPS/sip-28.md). A summary of the changes made: 1. The `CronjobController` was updated to now include logic for handling and storing background events. 2. `snap_scheduleBackgroundEvent` RPC method was added to schedule an event. 3. `snap_cancelBackgroundEvent` RPC method was added to cancel an event. 4. `snap_getBackgroundEvents` RPC method was added to get a snap's background events (not outlined in the SIP, but will be added as an addendum soon). --- .../browserify-plugin/snap.manifest.json | 2 +- .../packages/browserify/snap.manifest.json | 2 +- .../packages/cronjobs/snap.manifest.json | 9 +- .../examples/packages/cronjobs/src/index.ts | 68 ++- .../examples/packages/cronjobs/src/types.ts | 17 + packages/snaps-controllers/coverage.json | 8 +- packages/snaps-controllers/package.json | 2 + .../src/cronjob/CronjobController.test.ts | 548 +++++++++++++++++- .../src/cronjob/CronjobController.ts | 272 ++++++++- .../src/test-utils/controller.ts | 3 + packages/snaps-rpc-methods/jest.config.js | 8 +- packages/snaps-rpc-methods/package.json | 4 +- .../src/endowments/cronjob.test.ts | 145 ++++- .../src/endowments/cronjob.ts | 11 +- .../snaps-rpc-methods/src/permissions.test.ts | 1 + .../permitted/cancelBackgroundEvent.test.ts | 202 +++++++ .../src/permitted/cancelBackgroundEvent.ts | 105 ++++ .../src/permitted/getBackgroundEvents.test.ts | 220 +++++++ .../src/permitted/getBackgroundEvents.ts | 68 +++ .../src/permitted/handlers.ts | 6 + .../snaps-rpc-methods/src/permitted/index.ts | 6 + .../permitted/scheduleBackgroundEvent.test.ts | 276 +++++++++ .../src/permitted/scheduleBackgroundEvent.ts | 151 +++++ .../types/methods/cancel-background-event.ts | 15 + .../types/methods/get-background-events.ts | 39 ++ packages/snaps-sdk/src/types/methods/index.ts | 3 + .../snaps-sdk/src/types/methods/methods.ts | 24 + .../methods/schedule-background-event.ts | 17 + .../src/methods/specifications.test.ts | 1 + .../src/features/snaps/cronjobs/Cronjobs.tsx | 11 +- .../components/CancelBackgroundEvent.tsx | 52 ++ .../components/GetBackgroundEvents.tsx | 39 ++ .../components/ScheduleBackgroundEvent.tsx | 59 ++ .../snaps/cronjobs/components/index.ts | 3 + yarn.lock | 18 + 35 files changed, 2353 insertions(+), 62 deletions(-) create mode 100644 packages/examples/packages/cronjobs/src/types.ts create mode 100644 packages/snaps-rpc-methods/src/permitted/cancelBackgroundEvent.test.ts create mode 100644 packages/snaps-rpc-methods/src/permitted/cancelBackgroundEvent.ts create mode 100644 packages/snaps-rpc-methods/src/permitted/getBackgroundEvents.test.ts create mode 100644 packages/snaps-rpc-methods/src/permitted/getBackgroundEvents.ts create mode 100644 packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.test.ts create mode 100644 packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.ts create mode 100644 packages/snaps-sdk/src/types/methods/cancel-background-event.ts create mode 100644 packages/snaps-sdk/src/types/methods/get-background-events.ts create mode 100644 packages/snaps-sdk/src/types/methods/schedule-background-event.ts create mode 100644 packages/test-snaps/src/features/snaps/cronjobs/components/CancelBackgroundEvent.tsx create mode 100644 packages/test-snaps/src/features/snaps/cronjobs/components/GetBackgroundEvents.tsx create mode 100644 packages/test-snaps/src/features/snaps/cronjobs/components/ScheduleBackgroundEvent.tsx create mode 100644 packages/test-snaps/src/features/snaps/cronjobs/components/index.ts diff --git a/packages/examples/packages/browserify-plugin/snap.manifest.json b/packages/examples/packages/browserify-plugin/snap.manifest.json index 551a11c430..c38dc1cfce 100644 --- a/packages/examples/packages/browserify-plugin/snap.manifest.json +++ b/packages/examples/packages/browserify-plugin/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snaps.git" }, "source": { - "shasum": "82KbG3cf0wtxooJpWzHeM1g4FhO8O7zSYCAAGNPshfM=", + "shasum": "hy0TMeQeqznNQRX2j7DnxRt1Nn5Z+v0rjaWNpe1fEWE=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/examples/packages/browserify/snap.manifest.json b/packages/examples/packages/browserify/snap.manifest.json index f12d23acb3..cd6594a575 100644 --- a/packages/examples/packages/browserify/snap.manifest.json +++ b/packages/examples/packages/browserify/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snaps.git" }, "source": { - "shasum": "5LsB950haZGnl0q5K7M4XgSh5J2e0p5O1Ptl/e6kpSQ=", + "shasum": "VR3Zwjo0yqKLkuKHGDfS9AmuyW3KMbXSmi9Nh9JgCMw=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/examples/packages/cronjobs/snap.manifest.json b/packages/examples/packages/cronjobs/snap.manifest.json index aa7e65be6a..6a45524c37 100644 --- a/packages/examples/packages/cronjobs/snap.manifest.json +++ b/packages/examples/packages/cronjobs/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snaps.git" }, "source": { - "shasum": "E8SzmLlC0rgCn0qOop9/zCLmrWLABS5LlnaQv/MYkUc=", + "shasum": "6RCKkCSH+tCAKsXIzAjpaZrWjvGDXbkMcuaCslVvwr4=", "location": { "npm": { "filePath": "dist/bundle.js", @@ -17,6 +17,10 @@ } }, "initialPermissions": { + "endowment:rpc": { + "dapps": true, + "snaps": false + }, "endowment:cronjob": { "jobs": [ { @@ -27,7 +31,8 @@ } ] }, - "snap_dialog": {} + "snap_dialog": {}, + "snap_notify": {} }, "platformVersion": "6.14.0", "manifestVersion": "0.1" diff --git a/packages/examples/packages/cronjobs/src/index.ts b/packages/examples/packages/cronjobs/src/index.ts index 56930dd1e7..e22d989580 100644 --- a/packages/examples/packages/cronjobs/src/index.ts +++ b/packages/examples/packages/cronjobs/src/index.ts @@ -1,14 +1,25 @@ -import type { OnCronjobHandler } from '@metamask/snaps-sdk'; +import type { + OnCronjobHandler, + OnRpcRequestHandler, +} from '@metamask/snaps-sdk'; import { panel, text, heading, MethodNotFoundError } from '@metamask/snaps-sdk'; +import type { + CancelNotificationParams, + ScheduleNotificationParams, +} from './types'; + /** - * Handle cronjob execution requests from MetaMask. This handler handles one - * method: + * Handle cronjob execution requests from MetaMask. This handler handles two + * methods: * * - `execute`: The JSON-RPC method that is called by MetaMask when the cronjob * is triggered. This method is specified in the snap manifest under the * `endowment:cronjob` permission. If you want to support more methods (e.g., * with different times), you can add them to the manifest there. + * - `fireNotification`: The JSON-RPC method that is called by MetaMask when the + * background event is triggered. This method call is scheduled by the `scheduleNotification` + * method in the `onRpcRequest` handler. * * @param params - The request parameters. * @param params.request - The JSON-RPC request object. @@ -29,6 +40,57 @@ export const onCronjob: OnCronjobHandler = async ({ request }) => { ]), }, }); + case 'fireNotification': + return snap.request({ + method: 'snap_notify', + params: { + type: 'inApp', + message: 'Hello world!', + }, + }); + default: + // eslint-disable-next-line @typescript-eslint/no-throw-literal + throw new MethodNotFoundError({ method: request.method }); + } +}; + +/** + * Handle incoming JSON-RPC requests from the dapp, sent through the + * `wallet_invokeSnap` method. This handler handles three methods: + * + * - `scheduleNotification`: Schedule a notification in the future. + * - `cancelNotification`: Cancel a notification. + * - `getBackgroundEvents`: Get the Snap's background events. + * + * @param params - The request parameters. + * @param params.request - The JSON-RPC request object. + * @returns The JSON-RPC response. + * @see https://docs.metamask.io/snaps/reference/exports/#onrpcrequest + * @see https://docs.metamask.io/snaps/reference/rpc-api/#wallet_invokesnap + */ +export const onRpcRequest: OnRpcRequestHandler = async ({ request }) => { + switch (request.method) { + case 'scheduleNotification': + return snap.request({ + method: 'snap_scheduleBackgroundEvent', + params: { + date: (request.params as ScheduleNotificationParams).date, + request: { + method: 'fireNotification', + }, + }, + }); + case 'cancelNotification': + return snap.request({ + method: 'snap_cancelBackgroundEvent', + params: { + id: (request.params as CancelNotificationParams).id, + }, + }); + case 'getBackgroundEvents': + return snap.request({ + method: 'snap_getBackgroundEvents', + }); default: // eslint-disable-next-line @typescript-eslint/no-throw-literal throw new MethodNotFoundError({ method: request.method }); diff --git a/packages/examples/packages/cronjobs/src/types.ts b/packages/examples/packages/cronjobs/src/types.ts new file mode 100644 index 0000000000..c379c3fe4b --- /dev/null +++ b/packages/examples/packages/cronjobs/src/types.ts @@ -0,0 +1,17 @@ +/** + * The parameters for calling the `scheduleNotification` JSON-RPC method. + * + * @property date - The ISO 8601 date of when the notification should be scheduled. + */ +export type ScheduleNotificationParams = { + date: string; +}; + +/** + * The parameters for calling the `cancelNotification` JSON-RPC method. + * + * @property id - The id of the notification event to cancel. + */ +export type CancelNotificationParams = { + id: string; +}; diff --git a/packages/snaps-controllers/coverage.json b/packages/snaps-controllers/coverage.json index 74c70ee306..357e17f097 100644 --- a/packages/snaps-controllers/coverage.json +++ b/packages/snaps-controllers/coverage.json @@ -1,6 +1,6 @@ { - "branches": 92.96, - "functions": 96.71, - "lines": 98.01, - "statements": 97.71 + "branches": 93.06, + "functions": 96.54, + "lines": 98.02, + "statements": 97.74 } diff --git a/packages/snaps-controllers/package.json b/packages/snaps-controllers/package.json index ea3c81e193..1cbbdf7353 100644 --- a/packages/snaps-controllers/package.json +++ b/packages/snaps-controllers/package.json @@ -100,6 +100,7 @@ "fast-deep-equal": "^3.1.3", "get-npm-tarball-url": "^2.0.3", "immer": "^9.0.6", + "luxon": "^3.5.0", "nanoid": "^3.1.31", "readable-stream": "^3.6.2", "readable-web-to-node-stream": "^3.0.2", @@ -124,6 +125,7 @@ "@types/concat-stream": "^2.0.0", "@types/gunzip-maybe": "^1.4.0", "@types/jest": "^27.5.1", + "@types/luxon": "^3", "@types/mocha": "^10.0.1", "@types/node": "18.14.2", "@types/readable-stream": "^4.0.15", diff --git a/packages/snaps-controllers/src/cronjob/CronjobController.test.ts b/packages/snaps-controllers/src/cronjob/CronjobController.test.ts index a33f8ed9e7..4d65257d6a 100644 --- a/packages/snaps-controllers/src/cronjob/CronjobController.test.ts +++ b/packages/snaps-controllers/src/cronjob/CronjobController.test.ts @@ -18,7 +18,7 @@ describe('CronjobController', () => { const originalProcessNextTick = process.nextTick; beforeEach(() => { - jest.useFakeTimers().setSystemTime(new Date('2022-01-01')); + jest.useFakeTimers().setSystemTime(new Date('2022-01-01T00:00Z')); }); afterAll(() => { @@ -114,6 +114,7 @@ describe('CronjobController', () => { jobs: { [`${MOCK_SNAP_ID}-0`]: { lastRun: 0 }, }, + events: {}, }; }); @@ -166,6 +167,7 @@ describe('CronjobController', () => { jobs: { [`${MOCK_SNAP_ID}-0`]: { lastRun: 0 }, }, + events: {}, }, }); @@ -242,6 +244,235 @@ describe('CronjobController', () => { cronjobController.destroy(); }); + it('schedules a background event', () => { + const rootMessenger = getRootCronjobControllerMessenger(); + const controllerMessenger = + getRestrictedCronjobControllerMessenger(rootMessenger); + + const cronjobController = new CronjobController({ + messenger: controllerMessenger, + }); + + const backgroundEvent = { + snapId: MOCK_SNAP_ID, + date: '2022-01-01T01:00Z', + request: { + method: 'handleEvent', + params: ['p1'], + }, + }; + + const id = cronjobController.scheduleBackgroundEvent(backgroundEvent); + + expect(cronjobController.state.events).toStrictEqual({ + [id]: { id, scheduledAt: expect.any(String), ...backgroundEvent }, + }); + + jest.advanceTimersByTime(inMilliseconds(1, Duration.Day)); + + expect(rootMessenger.call).toHaveBeenCalledWith( + 'SnapController:handleRequest', + { + snapId: MOCK_SNAP_ID, + origin: '', + handler: HandlerType.OnCronjob, + request: { + method: 'handleEvent', + params: ['p1'], + }, + }, + ); + + expect(cronjobController.state.events).toStrictEqual({}); + + cronjobController.destroy(); + }); + + it('fails to schedule a background event if the date is in the past', () => { + const rootMessenger = getRootCronjobControllerMessenger(); + const controllerMessenger = + getRestrictedCronjobControllerMessenger(rootMessenger); + + const cronjobController = new CronjobController({ + messenger: controllerMessenger, + }); + + const backgroundEvent = { + snapId: MOCK_SNAP_ID, + date: '2021-01-01T01:00Z', + request: { + method: 'handleEvent', + params: ['p1'], + }, + }; + + expect(() => + cronjobController.scheduleBackgroundEvent(backgroundEvent), + ).toThrow('Cannot schedule an event in the past.'); + + expect(cronjobController.state.events).toStrictEqual({}); + + cronjobController.destroy(); + }); + + it('cancels a background event', () => { + const rootMessenger = getRootCronjobControllerMessenger(); + const controllerMessenger = + getRestrictedCronjobControllerMessenger(rootMessenger); + + const cronjobController = new CronjobController({ + messenger: controllerMessenger, + }); + + const backgroundEvent = { + snapId: MOCK_SNAP_ID, + date: '2022-01-01T01:00Z', + request: { + method: 'handleEvent', + params: ['p1'], + }, + }; + + const id = cronjobController.scheduleBackgroundEvent(backgroundEvent); + + expect(cronjobController.state.events).toStrictEqual({ + [id]: { id, scheduledAt: expect.any(String), ...backgroundEvent }, + }); + + cronjobController.cancelBackgroundEvent(MOCK_SNAP_ID, id); + + jest.advanceTimersByTime(inMilliseconds(1, Duration.Day)); + + expect(rootMessenger.call).not.toHaveBeenCalledWith( + 'SnapController:handleRequest', + { + snapId: MOCK_SNAP_ID, + origin: '', + handler: HandlerType.OnCronjob, + request: { + method: 'handleEvent', + params: ['p1'], + }, + }, + ); + + expect(cronjobController.state.events).toStrictEqual({}); + + cronjobController.destroy(); + }); + + it('fails to cancel a background event if the caller is not the scheduler', () => { + const rootMessenger = getRootCronjobControllerMessenger(); + const controllerMessenger = + getRestrictedCronjobControllerMessenger(rootMessenger); + + const cronjobController = new CronjobController({ + messenger: controllerMessenger, + }); + + const backgroundEvent = { + snapId: MOCK_SNAP_ID, + date: '2022-01-01T01:00Z', + request: { + method: 'handleEvent', + params: ['p1'], + }, + }; + + const id = cronjobController.scheduleBackgroundEvent(backgroundEvent); + + expect(cronjobController.state.events).toStrictEqual({ + [id]: { id, scheduledAt: expect.any(String), ...backgroundEvent }, + }); + + expect(() => cronjobController.cancelBackgroundEvent('foo', id)).toThrow( + 'Only the origin that scheduled this event can cancel it.', + ); + + cronjobController.destroy(); + }); + + it("returns a list of a Snap's background events", () => { + const rootMessenger = getRootCronjobControllerMessenger(); + const controllerMessenger = + getRestrictedCronjobControllerMessenger(rootMessenger); + + const cronjobController = new CronjobController({ + messenger: controllerMessenger, + }); + + const backgroundEvent = { + snapId: MOCK_SNAP_ID, + date: '2022-01-01T01:00Z', + request: { + method: 'handleEvent', + params: ['p1'], + }, + }; + + const id = cronjobController.scheduleBackgroundEvent(backgroundEvent); + + const events = cronjobController.getBackgroundEvents(MOCK_SNAP_ID); + expect(events).toStrictEqual([ + { + id, + snapId: MOCK_SNAP_ID, + date: '2022-01-01T01:00Z', + request: { + method: 'handleEvent', + params: ['p1'], + }, + scheduledAt: expect.any(String), + }, + ]); + + cronjobController.destroy(); + }); + + it('reschedules any un-expired events that are in state upon initialization', () => { + const rootMessenger = getRootCronjobControllerMessenger(); + const controllerMessenger = + getRestrictedCronjobControllerMessenger(rootMessenger); + + const cronjobController = new CronjobController({ + messenger: controllerMessenger, + state: { + jobs: {}, + events: { + foo: { + id: 'foo', + scheduledAt: new Date().toISOString(), + snapId: MOCK_SNAP_ID, + date: '2022-01-01T01:00Z', + request: { + method: 'handleEvent', + params: ['p1'], + }, + }, + }, + }, + }); + + jest.advanceTimersByTime(inMilliseconds(1, Duration.Day)); + + expect(rootMessenger.call).toHaveBeenCalledWith( + 'SnapController:handleRequest', + { + snapId: MOCK_SNAP_ID, + origin: '', + handler: HandlerType.OnCronjob, + request: { + method: 'handleEvent', + params: ['p1'], + }, + }, + ); + + expect(cronjobController.state.events).toStrictEqual({}); + + cronjobController.destroy(); + }); + it('handles SnapInstalled event', () => { const rootMessenger = getRootCronjobControllerMessenger(); const controllerMessenger = @@ -291,6 +522,31 @@ describe('CronjobController', () => { const cronjobController = new CronjobController({ messenger: controllerMessenger, + state: { + jobs: {}, + events: { + foo: { + id: 'foo', + scheduledAt: new Date().toISOString(), + snapId: MOCK_SNAP_ID, + date: '2022-01-01T01:00Z', + request: { + method: 'handleEvent', + params: ['p1'], + }, + }, + bar: { + id: 'bar', + scheduledAt: new Date().toISOString(), + snapId: MOCK_SNAP_ID, + date: '2021-01-01T01:00Z', + request: { + method: 'handleEvent', + params: ['p1'], + }, + }, + }, + }, }); const snapInfo: TruncatedSnap = { @@ -303,7 +559,20 @@ describe('CronjobController', () => { rootMessenger.publish('SnapController:snapEnabled', snapInfo); - jest.advanceTimersByTime(inMilliseconds(1, Duration.Minute)); + expect(cronjobController.state.events).toStrictEqual({ + foo: { + id: 'foo', + scheduledAt: new Date().toISOString(), + snapId: MOCK_SNAP_ID, + date: '2022-01-01T01:00Z', + request: { + method: 'handleEvent', + params: ['p1'], + }, + }, + }); + + jest.advanceTimersByTime(inMilliseconds(1, Duration.Day)); expect(rootMessenger.call).toHaveBeenNthCalledWith( 4, @@ -319,6 +588,19 @@ describe('CronjobController', () => { }, ); + expect(rootMessenger.call).toHaveBeenCalledWith( + 'SnapController:handleRequest', + { + snapId: MOCK_SNAP_ID, + origin: '', + handler: HandlerType.OnCronjob, + request: { + method: 'handleEvent', + params: ['p1'], + }, + }, + ); + cronjobController.destroy(); }); @@ -339,6 +621,15 @@ describe('CronjobController', () => { version: MOCK_VERSION, }; + cronjobController.scheduleBackgroundEvent({ + snapId: MOCK_SNAP_ID, + date: '2022-01-01T01:00Z', + request: { + method: 'handleEvent', + params: ['p1'], + }, + }); + rootMessenger.publish( 'SnapController:snapInstalled', snapInfo, @@ -362,6 +653,23 @@ describe('CronjobController', () => { }, ); + jest.advanceTimersByTime(inMilliseconds(1, Duration.Day)); + + expect(rootMessenger.call).not.toHaveBeenCalledWith( + 'SnapController:handleRequest', + { + snapId: MOCK_SNAP_ID, + origin: '', + handler: HandlerType.OnCronjob, + request: { + method: 'handleEvent', + params: ['p1'], + }, + }, + ); + + expect(cronjobController.state.events).toStrictEqual({}); + cronjobController.destroy(); }); @@ -382,6 +690,15 @@ describe('CronjobController', () => { version: MOCK_VERSION, }; + const id = cronjobController.scheduleBackgroundEvent({ + snapId: MOCK_SNAP_ID, + date: '2022-01-01T01:00Z', + request: { + method: 'handleEvent', + params: ['p1'], + }, + }); + rootMessenger.publish( 'SnapController:snapInstalled', snapInfo, @@ -405,6 +722,34 @@ describe('CronjobController', () => { }, ); + jest.advanceTimersByTime(inMilliseconds(1, Duration.Day)); + + expect(rootMessenger.call).not.toHaveBeenCalledWith( + 'SnapController:handleRequest', + { + snapId: MOCK_SNAP_ID, + origin: '', + handler: HandlerType.OnCronjob, + request: { + method: 'handleEvent', + params: ['p1'], + }, + }, + ); + + expect(cronjobController.state.events).toStrictEqual({ + [id]: { + id, + scheduledAt: expect.any(String), + snapId: MOCK_SNAP_ID, + date: '2022-01-01T01:00Z', + request: { + method: 'handleEvent', + params: ['p1'], + }, + }, + }); + cronjobController.destroy(); }); @@ -415,6 +760,21 @@ describe('CronjobController', () => { const cronjobController = new CronjobController({ messenger: controllerMessenger, + state: { + jobs: {}, + events: { + foo: { + id: 'foo', + scheduledAt: new Date().toISOString(), + snapId: MOCK_SNAP_ID, + date: '2022-01-01T01:00Z', + request: { + method: 'handleEvent', + params: ['p1'], + }, + }, + }, + }, }); const snapInfo: TruncatedSnap = { @@ -438,6 +798,8 @@ describe('CronjobController', () => { MOCK_ORIGIN, ); + expect(cronjobController.state.events).toStrictEqual({}); + jest.advanceTimersByTime(inMilliseconds(15, Duration.Minute)); expect(rootMessenger.call).toHaveBeenNthCalledWith( @@ -454,6 +816,20 @@ describe('CronjobController', () => { }, ); + expect(rootMessenger.call).not.toHaveBeenCalledWith( + 5, + 'SnapController:handleRequest', + { + snapId: MOCK_SNAP_ID, + origin: '', + handler: HandlerType.OnCronjob, + request: { + method: 'handleEvent', + params: ['p1'], + }, + }, + ); + cronjobController.destroy(); }); @@ -490,4 +866,172 @@ describe('CronjobController', () => { }, ); }); + + describe('CronjobController actions', () => { + describe('CronjobController:scheduleBackgroundEvent', () => { + it('schedules a background event', () => { + const rootMessenger = getRootCronjobControllerMessenger(); + const controllerMessenger = + getRestrictedCronjobControllerMessenger(rootMessenger); + + const cronjobController = new CronjobController({ + messenger: controllerMessenger, + }); + + cronjobController.register(MOCK_SNAP_ID); + + const id = rootMessenger.call( + 'CronjobController:scheduleBackgroundEvent', + { + snapId: MOCK_SNAP_ID, + date: '2022-01-01T01:00Z', + request: { + method: 'handleExport', + params: ['p1'], + }, + }, + ); + + expect(cronjobController.state.events).toStrictEqual({ + [id]: { + id, + snapId: MOCK_SNAP_ID, + scheduledAt: expect.any(String), + date: '2022-01-01T01:00Z', + request: { + method: 'handleExport', + params: ['p1'], + }, + }, + }); + + jest.advanceTimersByTime(inMilliseconds(1, Duration.Day)); + + expect(rootMessenger.call).toHaveBeenCalledWith( + 'SnapController:handleRequest', + { + snapId: MOCK_SNAP_ID, + origin: '', + handler: HandlerType.OnCronjob, + request: { + method: 'handleExport', + params: ['p1'], + }, + }, + ); + + expect(cronjobController.state.events).toStrictEqual({}); + + cronjobController.destroy(); + }); + }); + + describe('CronjobController:cancelBackgroundEvent', () => { + it('cancels a background event', () => { + const rootMessenger = getRootCronjobControllerMessenger(); + const controllerMessenger = + getRestrictedCronjobControllerMessenger(rootMessenger); + + const cronjobController = new CronjobController({ + messenger: controllerMessenger, + }); + + cronjobController.register(MOCK_SNAP_ID); + + const id = rootMessenger.call( + 'CronjobController:scheduleBackgroundEvent', + { + snapId: MOCK_SNAP_ID, + date: '2022-01-01T01:00Z', + request: { + method: 'handleExport', + params: ['p1'], + }, + }, + ); + + expect(cronjobController.state.events).toStrictEqual({ + [id]: { + id, + snapId: MOCK_SNAP_ID, + scheduledAt: expect.any(String), + date: '2022-01-01T01:00Z', + request: { + method: 'handleExport', + params: ['p1'], + }, + }, + }); + + rootMessenger.call( + 'CronjobController:cancelBackgroundEvent', + MOCK_SNAP_ID, + id, + ); + + expect(cronjobController.state.events).toStrictEqual({}); + + cronjobController.destroy(); + }); + }); + + describe('CronjobController:getBackgroundEvents', () => { + it("gets a list of a Snap's background events", () => { + const rootMessenger = getRootCronjobControllerMessenger(); + const controllerMessenger = + getRestrictedCronjobControllerMessenger(rootMessenger); + + const cronjobController = new CronjobController({ + messenger: controllerMessenger, + }); + + cronjobController.register(MOCK_SNAP_ID); + + const id = rootMessenger.call( + 'CronjobController:scheduleBackgroundEvent', + { + snapId: MOCK_SNAP_ID, + date: '2022-01-01T01:00Z', + request: { + method: 'handleExport', + params: ['p1'], + }, + }, + ); + + expect(cronjobController.state.events).toStrictEqual({ + [id]: { + id, + snapId: MOCK_SNAP_ID, + scheduledAt: expect.any(String), + date: '2022-01-01T01:00Z', + request: { + method: 'handleExport', + params: ['p1'], + }, + }, + }); + + const events = rootMessenger.call( + 'CronjobController:getBackgroundEvents', + MOCK_SNAP_ID, + ); + + expect(events).toStrictEqual([ + { + id, + snapId: MOCK_SNAP_ID, + scheduledAt: expect.any(String), + date: '2022-01-01T01:00Z', + request: { + method: 'handleExport', + params: ['p1'], + }, + }, + ]); + + cronjobController.destroy(); + }); + }); + }); }); diff --git a/packages/snaps-controllers/src/cronjob/CronjobController.ts b/packages/snaps-controllers/src/cronjob/CronjobController.ts index af01edab9e..f93995c9c2 100644 --- a/packages/snaps-controllers/src/cronjob/CronjobController.ts +++ b/packages/snaps-controllers/src/cronjob/CronjobController.ts @@ -9,7 +9,7 @@ import { getCronjobCaveatJobs, SnapEndowments, } from '@metamask/snaps-rpc-methods'; -import type { SnapId } from '@metamask/snaps-sdk'; +import type { BackgroundEvent, SnapId } from '@metamask/snaps-sdk'; import type { TruncatedSnap, CronjobSpecification, @@ -18,8 +18,12 @@ import { HandlerType, parseCronExpression, logError, + logWarning, } from '@metamask/snaps-utils'; -import { Duration, inMilliseconds } from '@metamask/utils'; +import { assert, Duration, inMilliseconds } from '@metamask/utils'; +import { castDraft } from 'immer'; +import { DateTime } from 'luxon'; +import { nanoid } from 'nanoid'; import type { GetAllSnaps, @@ -41,11 +45,30 @@ export type CronjobControllerStateChangeEvent = ControllerStateChangeEvent< typeof controllerName, CronjobControllerState >; + +export type ScheduleBackgroundEvent = { + type: `${typeof controllerName}:scheduleBackgroundEvent`; + handler: CronjobController['scheduleBackgroundEvent']; +}; + +export type CancelBackgroundEvent = { + type: `${typeof controllerName}:cancelBackgroundEvent`; + handler: CronjobController['cancelBackgroundEvent']; +}; + +export type GetBackgroundEvents = { + type: `${typeof controllerName}:getBackgroundEvents`; + handler: CronjobController['getBackgroundEvents']; +}; + export type CronjobControllerActions = | GetAllSnaps | HandleSnapRequest | GetPermissions - | CronjobControllerGetStateAction; + | CronjobControllerGetStateAction + | ScheduleBackgroundEvent + | CancelBackgroundEvent + | GetBackgroundEvents; export type CronjobControllerEvents = | SnapInstalled @@ -85,6 +108,7 @@ export type StoredJobInformation = { export type CronjobControllerState = { jobs: Record; + events: Record; }; const controllerName = 'CronjobController'; @@ -105,17 +129,19 @@ export class CronjobController extends BaseController< #timers: Map; // Mapping from jobId to snapId - #snapIds: Map; + #snapIds: Map; constructor({ messenger, state }: CronjobControllerArgs) { super({ messenger, metadata: { jobs: { persist: true, anonymous: false }, + events: { persist: true, anonymous: false }, }, name: controllerName, state: { jobs: {}, + events: {}, ...state, }, }); @@ -127,9 +153,11 @@ export class CronjobController extends BaseController< this._handleSnapUnregisterEvent = this._handleSnapUnregisterEvent.bind(this); this._handleEventSnapUpdated = this._handleEventSnapUpdated.bind(this); - + this._handleSnapDisabledEvent = this._handleSnapDisabledEvent.bind(this); + this._handleSnapEnabledEvent = this._handleSnapEnabledEvent.bind(this); // Subscribe to Snap events /* eslint-disable @typescript-eslint/unbound-method */ + this.messagingSystem.subscribe( 'SnapController:snapInstalled', this._handleSnapRegisterEvent, @@ -142,12 +170,12 @@ export class CronjobController extends BaseController< this.messagingSystem.subscribe( 'SnapController:snapEnabled', - this._handleSnapRegisterEvent, + this._handleSnapEnabledEvent, ); this.messagingSystem.subscribe( 'SnapController:snapDisabled', - this._handleSnapUnregisterEvent, + this._handleSnapDisabledEvent, ); this.messagingSystem.subscribe( @@ -156,9 +184,26 @@ export class CronjobController extends BaseController< ); /* eslint-enable @typescript-eslint/unbound-method */ + this.messagingSystem.registerActionHandler( + `${controllerName}:scheduleBackgroundEvent`, + (...args) => this.scheduleBackgroundEvent(...args), + ); + + this.messagingSystem.registerActionHandler( + `${controllerName}:cancelBackgroundEvent`, + (...args) => this.cancelBackgroundEvent(...args), + ); + + this.messagingSystem.registerActionHandler( + `${controllerName}:getBackgroundEvents`, + (...args) => this.getBackgroundEvents(...args), + ); + this.dailyCheckIn().catch((error) => { logError(error); }); + + this.#rescheduleBackgroundEvents(Object.values(this.state.events)); } /** @@ -166,11 +211,11 @@ export class CronjobController extends BaseController< * * @returns Array of Cronjob specifications. */ - private getAllJobs(): Cronjob[] { + #getAllJobs(): Cronjob[] { const snaps = this.messagingSystem.call('SnapController:getAll'); const filteredSnaps = getRunnableSnaps(snaps); - const jobs = filteredSnaps.map((snap) => this.getSnapJobs(snap.id)); + const jobs = filteredSnaps.map((snap) => this.#getSnapJobs(snap.id)); // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion return jobs.flat().filter((job) => job !== undefined) as Cronjob[]; } @@ -181,7 +226,7 @@ export class CronjobController extends BaseController< * @param snapId - ID of a Snap. * @returns Array of Cronjob specifications. */ - private getSnapJobs(snapId: SnapId): Cronjob[] | undefined { + #getSnapJobs(snapId: SnapId): Cronjob[] | undefined { const permissions = this.#messenger.call( 'PermissionController:getPermissions', snapId, @@ -202,8 +247,8 @@ export class CronjobController extends BaseController< * @param snapId - ID of a snap. */ register(snapId: SnapId) { - const jobs = this.getSnapJobs(snapId); - jobs?.forEach((job) => this.schedule(job)); + const jobs = this.#getSnapJobs(snapId); + jobs?.forEach((job) => this.#schedule(job)); } /** @@ -217,7 +262,7 @@ export class CronjobController extends BaseController< * * @param job - Cronjob specification. */ - private schedule(job: Cronjob) { + #schedule(job: Cronjob) { if (this.#timers.has(job.id)) { return; } @@ -234,17 +279,17 @@ export class CronjobController extends BaseController< const timer = new Timer(ms); timer.start(() => { - this.executeCronjob(job).catch((error) => { + this.#executeCronjob(job).catch((error) => { // TODO: Decide how to handle errors. logError(error); }); this.#timers.delete(job.id); - this.schedule(job); + this.#schedule(job); }); if (!this.state.jobs[job.id]?.lastRun) { - this.updateJobLastRunState(job.id, 0); // 0 for init, never ran actually + this.#updateJobLastRunState(job.id, 0); // 0 for init, never ran actually } this.#timers.set(job.id, timer); @@ -256,8 +301,8 @@ export class CronjobController extends BaseController< * * @param job - Cronjob specification. */ - private async executeCronjob(job: Cronjob) { - this.updateJobLastRunState(job.id, Date.now()); + async #executeCronjob(job: Cronjob) { + this.#updateJobLastRunState(job.id, Date.now()); await this.#messenger.call('SnapController:handleRequest', { snapId: job.snapId, origin: '', @@ -267,24 +312,147 @@ export class CronjobController extends BaseController< } /** - * Unregister all jobs related to the given snapId. + * Schedule a background event. + * + * @param backgroundEventWithoutId - Background event. + * @returns An id representing the background event. + */ + scheduleBackgroundEvent( + backgroundEventWithoutId: Omit, + ) { + // Remove millisecond precision and convert to UTC. + const scheduledAt = DateTime.fromJSDate(new Date()) + .toUTC() + .startOf('second') + .toISO({ + suppressMilliseconds: true, + }); + + assert(scheduledAt); + + const event = { + ...backgroundEventWithoutId, + id: nanoid(), + scheduledAt, + }; + + this.#setUpBackgroundEvent(event); + this.update((state) => { + state.events[event.id] = castDraft(event); + }); + + return event.id; + } + + /** + * Cancel a background event. + * + * @param origin - The origin making the cancel call. + * @param id - The id of the background event to cancel. + * @throws If the event does not exist. + */ + cancelBackgroundEvent(origin: string, id: string) { + assert( + this.state.events[id], + `A background event with the id of "${id}" does not exist.`, + ); + + assert( + this.state.events[id].snapId === origin, + 'Only the origin that scheduled this event can cancel it.', + ); + + const timer = this.#timers.get(id); + timer?.cancel(); + this.#timers.delete(id); + this.#snapIds.delete(id); + this.update((state) => { + delete state.events[id]; + }); + } + + /** + * A helper function to handle setup of the background event. + * + * @param event - A background event. + */ + #setUpBackgroundEvent(event: BackgroundEvent) { + const date = new Date(event.date); + const now = new Date(); + const ms = date.getTime() - now.getTime(); + + if (ms <= 0) { + throw new Error('Cannot schedule an event in the past.'); + } + + const timer = new Timer(ms); + timer.start(() => { + this.#messenger + .call('SnapController:handleRequest', { + snapId: event.snapId, + origin: '', + handler: HandlerType.OnCronjob, + request: event.request, + }) + .catch((error) => { + logError(error); + }); + + this.#timers.delete(event.id); + this.#snapIds.delete(event.id); + this.update((state) => { + delete state.events[event.id]; + }); + }); + + this.#timers.set(event.id, timer); + this.#snapIds.set(event.id, event.snapId); + } + + /** + * Get a list of a Snap's background events. + * + * @param snapId - The id of the Snap to fetch background events for. + * @returns An array of background events. + */ + getBackgroundEvents(snapId: SnapId): BackgroundEvent[] { + return Object.values(this.state.events).filter( + (snapEvent) => snapEvent.snapId === snapId, + ); + } + + /** + * Unregister all jobs and background events related to the given snapId. * * @param snapId - ID of a snap. + * @param skipEvents - Whether the unregistration process should skip scheduled background events. */ - unregister(snapId: string) { + unregister(snapId: SnapId, skipEvents = false) { const jobs = [...this.#snapIds.entries()].filter( ([_, jobSnapId]) => jobSnapId === snapId, ); if (jobs.length) { + const eventIds: string[] = []; jobs.forEach(([id]) => { const timer = this.#timers.get(id); if (timer) { timer.cancel(); this.#timers.delete(id); this.#snapIds.delete(id); + if (!skipEvents && this.state.events[id]) { + eventIds.push(id); + } } }); + + if (eventIds.length > 0) { + this.update((state) => { + eventIds.forEach((id) => { + delete state.events[id]; + }); + }); + } } } @@ -294,7 +462,7 @@ export class CronjobController extends BaseController< * @param jobId - ID of a cron job. * @param lastRun - Unix timestamp when the job was last ran. */ - private updateJobLastRunState(jobId: string, lastRun: number) { + #updateJobLastRunState(jobId: string, lastRun: number) { this.update((state) => { state.jobs[jobId] = { lastRun, @@ -308,7 +476,7 @@ export class CronjobController extends BaseController< * This is necessary for longer running jobs that execute with more than 24 hours between them. */ async dailyCheckIn() { - const jobs = this.getAllJobs(); + const jobs = this.#getAllJobs(); for (const job of jobs) { const parsed = parseCronExpression(job.expression); @@ -319,11 +487,11 @@ export class CronjobController extends BaseController< parsed.hasPrev() && parsed.prev().getTime() > lastRun ) { - await this.executeCronjob(job); + await this.#executeCronjob(job); } // Try scheduling, will fail if an existing scheduled job is found - this.schedule(job); + this.#schedule(job); } this.#dailyTimer = new Timer(DAILY_TIMEOUT); @@ -335,6 +503,31 @@ export class CronjobController extends BaseController< }); } + /** + * Reschedule background events. + * + * @param backgroundEvents - A list of background events to reschdule. + */ + #rescheduleBackgroundEvents(backgroundEvents: BackgroundEvent[]) { + for (const snapEvent of backgroundEvents) { + const { date } = snapEvent; + const now = new Date(); + const then = new Date(date); + if (then.getTime() < now.getTime()) { + // Remove expired events from state + this.update((state) => { + delete state.events[snapEvent.id]; + }); + + logWarning( + `Background event with id "${snapEvent.id}" not scheduled as its date has expired.`, + ); + } else { + this.#setUpBackgroundEvent(snapEvent); + } + } + } + /** * Run controller teardown process and unsubscribe from Snap events. */ @@ -354,12 +547,12 @@ export class CronjobController extends BaseController< this.messagingSystem.unsubscribe( 'SnapController:snapEnabled', - this._handleSnapRegisterEvent, + this._handleSnapEnabledEvent, ); this.messagingSystem.unsubscribe( 'SnapController:snapDisabled', - this._handleSnapUnregisterEvent, + this._handleSnapDisabledEvent, ); this.messagingSystem.unsubscribe( @@ -368,9 +561,7 @@ export class CronjobController extends BaseController< ); /* eslint-enable @typescript-eslint/unbound-method */ - this.#snapIds.forEach((snapId) => { - this.unregister(snapId); - }); + this.#snapIds.forEach((snapId) => this.unregister(snapId)); } /** @@ -383,7 +574,19 @@ export class CronjobController extends BaseController< } /** - * Handle events that should cause cronjobs to be unregistered. + * Handle events that could cause cronjobs to be registered + * and for background events to be rescheduled. + * + * @param snap - Basic Snap information. + */ + private _handleSnapEnabledEvent(snap: TruncatedSnap) { + const events = this.getBackgroundEvents(snap.id); + this.#rescheduleBackgroundEvents(events); + this.register(snap.id); + } + + /** + * Handle events that should cause cronjobs and background events to be unregistered. * * @param snap - Basic Snap information. */ @@ -391,6 +594,15 @@ export class CronjobController extends BaseController< this.unregister(snap.id); } + /** + * Handle events that should cause cronjobs and background events to be unregistered. + * + * @param snap - Basic Snap information. + */ + private _handleSnapDisabledEvent(snap: TruncatedSnap) { + this.unregister(snap.id, true); + } + /** * Handle cron jobs on 'snapUpdated' event. * diff --git a/packages/snaps-controllers/src/test-utils/controller.ts b/packages/snaps-controllers/src/test-utils/controller.ts index d02ed0ee4a..d1d12b701c 100644 --- a/packages/snaps-controllers/src/test-utils/controller.ts +++ b/packages/snaps-controllers/src/test-utils/controller.ts @@ -675,6 +675,9 @@ export const getRestrictedCronjobControllerMessenger = ( 'PermissionController:getPermissions', 'SnapController:getAll', 'SnapController:handleRequest', + 'CronjobController:scheduleBackgroundEvent', + 'CronjobController:cancelBackgroundEvent', + 'CronjobController:getBackgroundEvents', ], }); diff --git a/packages/snaps-rpc-methods/jest.config.js b/packages/snaps-rpc-methods/jest.config.js index 3a4658da9f..fa993ad89b 100644 --- a/packages/snaps-rpc-methods/jest.config.js +++ b/packages/snaps-rpc-methods/jest.config.js @@ -10,10 +10,10 @@ module.exports = deepmerge(baseConfig, { ], coverageThreshold: { global: { - branches: 93.33, - functions: 97.46, - lines: 98.03, - statements: 97.61, + branches: 93.91, + functions: 98.02, + lines: 98.65, + statements: 98.24, }, }, }); diff --git a/packages/snaps-rpc-methods/package.json b/packages/snaps-rpc-methods/package.json index 5db3bd1627..0f06491057 100644 --- a/packages/snaps-rpc-methods/package.json +++ b/packages/snaps-rpc-methods/package.json @@ -62,7 +62,8 @@ "@metamask/snaps-utils": "workspace:^", "@metamask/superstruct": "^3.1.0", "@metamask/utils": "^10.0.0", - "@noble/hashes": "^1.3.1" + "@noble/hashes": "^1.3.1", + "luxon": "^3.5.0" }, "devDependencies": { "@lavamoat/allow-scripts": "^3.0.4", @@ -75,6 +76,7 @@ "@swc/core": "1.3.78", "@swc/jest": "^0.2.26", "@ts-bridge/cli": "^0.6.1", + "@types/luxon": "^3", "@types/node": "18.14.2", "@typescript-eslint/eslint-plugin": "^5.42.1", "@typescript-eslint/parser": "^6.21.0", diff --git a/packages/snaps-rpc-methods/src/endowments/cronjob.test.ts b/packages/snaps-rpc-methods/src/endowments/cronjob.test.ts index 632ce04326..bfeb66c7dc 100644 --- a/packages/snaps-rpc-methods/src/endowments/cronjob.test.ts +++ b/packages/snaps-rpc-methods/src/endowments/cronjob.test.ts @@ -1,12 +1,17 @@ -import type { Caveat } from '@metamask/permission-controller'; +import type { + Caveat, + PermissionConstraint, +} from '@metamask/permission-controller'; import { PermissionType, SubjectType } from '@metamask/permission-controller'; import { SnapCaveatType } from '@metamask/snaps-utils'; +import type { Json } from '@metamask/utils'; import { getCronjobCaveatMapper, cronjobEndowmentBuilder, validateCronjobCaveat, cronjobCaveatSpecifications, + getCronjobCaveatJobs, } from './cronjob'; import { SnapEndowments } from './enum'; @@ -20,6 +25,7 @@ describe('endowment:cronjob', () => { endowmentGetter: expect.any(Function), allowedCaveats: [SnapCaveatType.SnapCronjob], subjectTypes: [SubjectType.Snap], + validator: expect.any(Function), }); expect(specification.endowmentGetter()).toBeNull(); @@ -59,6 +65,132 @@ describe('endowment:cronjob', () => { ], }); }); + + it.each([undefined, 2, {}])( + 'returns a null caveats value for invalid values', + (val) => { + expect(getCronjobCaveatMapper(val as Json)).toStrictEqual({ + caveats: null, + }); + }, + ); + }); +}); + +describe('getCronjobCaveatJobs', () => { + it('returns the jobs from a cronjob caveat', () => { + const permission: PermissionConstraint = { + date: 0, + parentCapability: 'foo', + invoker: 'bar', + id: 'baz', + caveats: [ + { + type: SnapCaveatType.SnapCronjob, + value: { + jobs: [ + { + expression: '* * * * *', + request: { + method: 'exampleMethodOne', + params: ['p1'], + }, + }, + ], + }, + }, + ], + }; + + expect(getCronjobCaveatJobs(permission)).toStrictEqual([ + { + expression: '* * * * *', + request: { + method: 'exampleMethodOne', + params: ['p1'], + }, + }, + ]); + }); + + it('returns null if there are no caveats', () => { + const permission: PermissionConstraint = { + date: 0, + parentCapability: 'foo', + invoker: 'bar', + id: 'baz', + caveats: null, + }; + + expect(getCronjobCaveatJobs(permission)).toBeNull(); + }); + + it('throws if there is more than one caveat', () => { + const permission: PermissionConstraint = { + date: 0, + parentCapability: 'foo', + invoker: 'bar', + id: 'baz', + caveats: [ + { + type: SnapCaveatType.SnapCronjob, + value: { + jobs: [ + { + expression: '* * * * *', + request: { + method: 'exampleMethodOne', + params: ['p1'], + }, + }, + ], + }, + }, + { + type: SnapCaveatType.SnapCronjob, + value: { + jobs: [ + { + expression: '* * * * *', + request: { + method: 'exampleMethodOne', + params: ['p1'], + }, + }, + ], + }, + }, + ], + }; + + expect(() => getCronjobCaveatJobs(permission)).toThrow('Assertion failed.'); + }); + + it('throws if the caveat type is wrong', () => { + const permission: PermissionConstraint = { + date: 0, + parentCapability: 'foo', + invoker: 'bar', + id: 'baz', + caveats: [ + { + type: SnapCaveatType.ChainIds, + value: { + jobs: [ + { + expression: '* * * * *', + request: { + method: 'exampleMethodOne', + params: ['p1'], + }, + }, + ], + }, + }, + ], + }; + + expect(() => getCronjobCaveatJobs(permission)).toThrow('Assertion failed.'); }); }); @@ -82,17 +214,6 @@ describe('validateCronjobCaveat', () => { expect(() => validateCronjobCaveat(caveat)).not.toThrow(); }); - it('should throw if caveat has no proper value', () => { - const caveat: Caveat = { - type: SnapCaveatType.SnapCronjob, - value: {}, - }; - - expect(() => validateCronjobCaveat(caveat)).toThrow( - `Expected a plain object.`, - ); - }); - it('should throw an error when cron specification is missing', () => { const caveat: Caveat = { type: SnapCaveatType.SnapCronjob, diff --git a/packages/snaps-rpc-methods/src/endowments/cronjob.ts b/packages/snaps-rpc-methods/src/endowments/cronjob.ts index 380b98409f..aa4466deab 100644 --- a/packages/snaps-rpc-methods/src/endowments/cronjob.ts +++ b/packages/snaps-rpc-methods/src/endowments/cronjob.ts @@ -14,8 +14,9 @@ import { isCronjobSpecificationArray, } from '@metamask/snaps-utils'; import type { Json, NonEmptyArray } from '@metamask/utils'; -import { assert, hasProperty, isPlainObject } from '@metamask/utils'; +import { assert, hasProperty, isObject, isPlainObject } from '@metamask/utils'; +import { createGenericPermissionValidator } from './caveats'; import { SnapEndowments } from './enum'; const permissionName = SnapEndowments.Cronjob; @@ -44,6 +45,10 @@ const specificationBuilder: PermissionSpecificationBuilder< allowedCaveats: [SnapCaveatType.SnapCronjob], endowmentGetter: (_getterOptions?: EndowmentGetterParams) => null, subjectTypes: [SubjectType.Snap], + validator: createGenericPermissionValidator([ + { type: SnapCaveatType.SnapCronjob, optional: true }, + { type: SnapCaveatType.MaxRequestTime, optional: true }, + ]), }; }; @@ -63,6 +68,10 @@ export const cronjobEndowmentBuilder = Object.freeze({ export function getCronjobCaveatMapper( value: Json, ): Pick { + if (!value || !isObject(value) || Object.keys(value).length === 0) { + return { caveats: null }; + } + return { caveats: [ { diff --git a/packages/snaps-rpc-methods/src/permissions.test.ts b/packages/snaps-rpc-methods/src/permissions.test.ts index 7725f2cd0e..de38781944 100644 --- a/packages/snaps-rpc-methods/src/permissions.test.ts +++ b/packages/snaps-rpc-methods/src/permissions.test.ts @@ -18,6 +18,7 @@ describe('buildSnapEndowmentSpecifications', () => { "snap", ], "targetName": "endowment:cronjob", + "validator": [Function], }, "endowment:ethereum-provider": { "allowedCaveats": null, diff --git a/packages/snaps-rpc-methods/src/permitted/cancelBackgroundEvent.test.ts b/packages/snaps-rpc-methods/src/permitted/cancelBackgroundEvent.test.ts new file mode 100644 index 0000000000..bb5e382c7a --- /dev/null +++ b/packages/snaps-rpc-methods/src/permitted/cancelBackgroundEvent.test.ts @@ -0,0 +1,202 @@ +import { JsonRpcEngine } from '@metamask/json-rpc-engine'; +import type { + CancelBackgroundEventParams, + CancelBackgroundEventResult, +} from '@metamask/snaps-sdk'; +import { MOCK_SNAP_ID } from '@metamask/snaps-utils/test-utils'; +import type { JsonRpcRequest, PendingJsonRpcResponse } from '@metamask/utils'; + +import { cancelBackgroundEventHandler } from './cancelBackgroundEvent'; + +describe('snap_cancelBackgroundEvent', () => { + describe('cancelBackgroundEventHandler', () => { + it('has the expected shape', () => { + expect(cancelBackgroundEventHandler).toMatchObject({ + methodNames: ['snap_cancelBackgroundEvent'], + implementation: expect.any(Function), + hookNames: { + cancelBackgroundEvent: true, + }, + }); + }); + }); + + describe('implementation', () => { + const createOriginMiddleware = + (origin: string) => + (request: any, _response: unknown, next: () => void, _end: unknown) => { + request.origin = origin; + next(); + }; + + it('returns null after calling the `cancelBackgroundEvent` hook', async () => { + const { implementation } = cancelBackgroundEventHandler; + + const cancelBackgroundEvent = jest.fn(); + const hasPermission = jest.fn().mockImplementation(() => true); + + const hooks = { + cancelBackgroundEvent, + hasPermission, + }; + + const engine = new JsonRpcEngine(); + + engine.push(createOriginMiddleware(MOCK_SNAP_ID)); + engine.push((request, response, next, end) => { + const result = implementation( + request as JsonRpcRequest, + response as PendingJsonRpcResponse, + next, + end, + hooks, + ); + + result?.catch(end); + }); + + const response = await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: 'snap_cancelBackgroundEvent', + params: { + id: 'foo', + }, + }); + + expect(response).toStrictEqual({ jsonrpc: '2.0', id: 1, result: null }); + }); + + it('cancels a background event', async () => { + const { implementation } = cancelBackgroundEventHandler; + + const cancelBackgroundEvent = jest.fn(); + const hasPermission = jest.fn().mockImplementation(() => true); + + const hooks = { + cancelBackgroundEvent, + hasPermission, + }; + + const engine = new JsonRpcEngine(); + + engine.push(createOriginMiddleware(MOCK_SNAP_ID)); + engine.push((request, response, next, end) => { + const result = implementation( + request as JsonRpcRequest, + response as PendingJsonRpcResponse, + next, + end, + hooks, + ); + + result?.catch(end); + }); + + await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: 'snap_cancelBackgroundEvent', + params: { + id: 'foo', + }, + }); + + expect(cancelBackgroundEvent).toHaveBeenCalledWith('foo'); + }); + + it('throws if a snap does not have the "endowment:cronjob" permission', async () => { + const { implementation } = cancelBackgroundEventHandler; + + const cancelBackgroundEvent = jest.fn(); + const hasPermission = jest.fn().mockImplementation(() => false); + + const hooks = { + cancelBackgroundEvent, + hasPermission, + }; + + const engine = new JsonRpcEngine(); + + engine.push(createOriginMiddleware(MOCK_SNAP_ID)); + engine.push((request, response, next, end) => { + const result = implementation( + request as JsonRpcRequest, + response as PendingJsonRpcResponse, + next, + end, + hooks, + ); + + result?.catch(end); + }); + + const response = await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: 'snap_cancelBackgroundEvent', + params: { + id: 2, + }, + }); + + expect(response).toStrictEqual({ + error: { + code: 4100, + message: + 'The requested account and/or method has not been authorized by the user.', + stack: expect.any(String), + }, + id: 1, + jsonrpc: '2.0', + }); + }); + + it('throws on invalid params', async () => { + const { implementation } = cancelBackgroundEventHandler; + + const cancelBackgroundEvent = jest.fn(); + const hasPermission = jest.fn().mockImplementation(() => true); + + const hooks = { + cancelBackgroundEvent, + hasPermission, + }; + + const engine = new JsonRpcEngine(); + + engine.push(createOriginMiddleware(MOCK_SNAP_ID)); + engine.push((request, response, next, end) => { + const result = implementation( + request as JsonRpcRequest, + response as PendingJsonRpcResponse, + next, + end, + hooks, + ); + + result?.catch(end); + }); + + const response = await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: 'snap_cancelBackgroundEvent', + params: { + id: 2, + }, + }); + + expect(response).toStrictEqual({ + error: { + code: -32602, + message: + 'Invalid params: At path: id -- Expected a string, but received: 2.', + stack: expect.any(String), + }, + id: 1, + jsonrpc: '2.0', + }); + }); + }); +}); diff --git a/packages/snaps-rpc-methods/src/permitted/cancelBackgroundEvent.ts b/packages/snaps-rpc-methods/src/permitted/cancelBackgroundEvent.ts new file mode 100644 index 0000000000..2a83b3762a --- /dev/null +++ b/packages/snaps-rpc-methods/src/permitted/cancelBackgroundEvent.ts @@ -0,0 +1,105 @@ +import type { JsonRpcEngineEndCallback } from '@metamask/json-rpc-engine'; +import type { PermittedHandlerExport } from '@metamask/permission-controller'; +import { providerErrors, rpcErrors } from '@metamask/rpc-errors'; +import type { + JsonRpcRequest, + CancelBackgroundEventParams, + CancelBackgroundEventResult, +} from '@metamask/snaps-sdk'; +import { type InferMatching } from '@metamask/snaps-utils'; +import { StructError, create, object, string } from '@metamask/superstruct'; +import { type PendingJsonRpcResponse } from '@metamask/utils'; + +import { SnapEndowments } from '../endowments'; +import type { MethodHooksObject } from '../utils'; + +const methodName = 'snap_cancelBackgroundEvent'; + +const hookNames: MethodHooksObject = { + cancelBackgroundEvent: true, + hasPermission: true, +}; + +export type CancelBackgroundEventMethodHooks = { + cancelBackgroundEvent: (id: string) => void; + hasPermission: (permissionName: string) => boolean; +}; + +export const cancelBackgroundEventHandler: PermittedHandlerExport< + CancelBackgroundEventMethodHooks, + CancelBackgroundEventParameters, + CancelBackgroundEventResult +> = { + methodNames: [methodName], + implementation: getCancelBackgroundEventImplementation, + hookNames, +}; + +const CancelBackgroundEventsParametersStruct = object({ + id: string(), +}); + +export type CancelBackgroundEventParameters = InferMatching< + typeof CancelBackgroundEventsParametersStruct, + CancelBackgroundEventParams +>; + +/** + * The `snap_cancelBackgroundEvent` method implementation. + * + * @param req - The JSON-RPC request object. + * @param res - The JSON-RPC response object. + * @param _next - The `json-rpc-engine` "next" callback. Not used by this + * function. + * @param end - The `json-rpc-engine` "end" callback. + * @param hooks - The RPC method hooks. + * @param hooks.cancelBackgroundEvent - The function to cancel a background event. + * @param hooks.hasPermission - The function to check if a snap has the `endowment:cronjob` permission. + * @returns Nothing. + */ +async function getCancelBackgroundEventImplementation( + req: JsonRpcRequest, + res: PendingJsonRpcResponse, + _next: unknown, + end: JsonRpcEngineEndCallback, + { cancelBackgroundEvent, hasPermission }: CancelBackgroundEventMethodHooks, +): Promise { + const { params } = req; + + if (!hasPermission(SnapEndowments.Cronjob)) { + return end(providerErrors.unauthorized()); + } + + try { + const validatedParams = getValidatedParams(params); + + const { id } = validatedParams; + + cancelBackgroundEvent(id); + res.result = null; + } catch (error) { + return end(error); + } + + return end(); +} + +/** + * Validate the cancelBackgroundEvent method `params` and returns them cast to the correct type. Throws if validation fails. + * + * @param params - The unvalidated params object from the method request. + * @returns The validated resolveInterface method parameter object. + */ +function getValidatedParams(params: unknown): CancelBackgroundEventParameters { + try { + return create(params, CancelBackgroundEventsParametersStruct); + } catch (error) { + if (error instanceof StructError) { + throw rpcErrors.invalidParams({ + message: `Invalid params: ${error.message}.`, + }); + } + /* istanbul ignore next */ + throw rpcErrors.internal(); + } +} diff --git a/packages/snaps-rpc-methods/src/permitted/getBackgroundEvents.test.ts b/packages/snaps-rpc-methods/src/permitted/getBackgroundEvents.test.ts new file mode 100644 index 0000000000..c78af4cbb4 --- /dev/null +++ b/packages/snaps-rpc-methods/src/permitted/getBackgroundEvents.test.ts @@ -0,0 +1,220 @@ +import { JsonRpcEngine } from '@metamask/json-rpc-engine'; +import type { + GetBackgroundEventsParams, + GetBackgroundEventsResult, +} from '@metamask/snaps-sdk'; +import { MOCK_SNAP_ID } from '@metamask/snaps-utils/test-utils'; +import type { JsonRpcRequest, PendingJsonRpcResponse } from '@metamask/utils'; + +import { getBackgroundEventsHandler } from './getBackgroundEvents'; + +describe('snap_getBackgroundEvents', () => { + describe('getBackgroundEventsHandler', () => { + it('has the expected shape', () => { + expect(getBackgroundEventsHandler).toMatchObject({ + methodNames: ['snap_getBackgroundEvents'], + implementation: expect.any(Function), + hookNames: { + getBackgroundEvents: true, + }, + }); + }); + }); + + describe('implementation', () => { + const createOriginMiddleware = + (origin: string) => + (request: any, _response: unknown, next: () => void, _end: unknown) => { + request.origin = origin; + next(); + }; + + it('returns an array of background events after calling the `getBackgroundEvents` hook', async () => { + const { implementation } = getBackgroundEventsHandler; + + const backgroundEvents = [ + { + id: 'foo', + snapId: MOCK_SNAP_ID, + date: '2022-01-01T01:00Z', + scheduledAt: '2021-01-01', + request: { + method: 'handleExport', + params: ['p1'], + }, + }, + ]; + + const getBackgroundEvents = jest + .fn() + .mockImplementation(() => backgroundEvents); + + const hasPermission = jest.fn().mockImplementation(() => true); + + const hooks = { + getBackgroundEvents, + hasPermission, + }; + + const engine = new JsonRpcEngine(); + + engine.push(createOriginMiddleware(MOCK_SNAP_ID)); + engine.push((request, response, next, end) => { + const result = implementation( + request as JsonRpcRequest, + response as PendingJsonRpcResponse, + next, + end, + hooks, + ); + + result?.catch(end); + }); + + const response = await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: 'snap_getBackgroundEvents', + }); + + expect(response).toStrictEqual({ + jsonrpc: '2.0', + id: 1, + result: backgroundEvents, + }); + }); + + it('gets background events', async () => { + const { implementation } = getBackgroundEventsHandler; + + const getBackgroundEvents = jest.fn(); + + const hasPermission = jest.fn().mockImplementation(() => true); + + const hooks = { + getBackgroundEvents, + hasPermission, + }; + + const engine = new JsonRpcEngine(); + + engine.push(createOriginMiddleware(MOCK_SNAP_ID)); + engine.push((request, response, next, end) => { + const result = implementation( + request as JsonRpcRequest, + response as PendingJsonRpcResponse, + next, + end, + hooks, + ); + + result?.catch(end); + }); + + await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: 'snap_getBackgroundEvents', + }); + + expect(getBackgroundEvents).toHaveBeenCalled(); + }); + + it('will throw if the call to the `getBackgroundEvents` hook fails', async () => { + const { implementation } = getBackgroundEventsHandler; + + const getBackgroundEvents = jest.fn().mockImplementation(() => { + throw new Error('foobar'); + }); + + const hasPermission = jest.fn().mockImplementation(() => true); + + const hooks = { + getBackgroundEvents, + hasPermission, + }; + + const engine = new JsonRpcEngine(); + + engine.push(createOriginMiddleware(MOCK_SNAP_ID)); + engine.push((request, response, next, end) => { + const result = implementation( + request as JsonRpcRequest, + response as PendingJsonRpcResponse, + next, + end, + hooks, + ); + + result?.catch(end); + }); + + const response = await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: 'snap_getBackgroundEvent', + }); + + expect(response).toStrictEqual({ + error: { + code: -32603, + data: { + cause: expect.objectContaining({ + message: 'foobar', + }), + }, + message: 'foobar', + }, + id: 1, + jsonrpc: '2.0', + }); + }); + + it('throws if a snap does not have the "endowment:cronjob" permission', async () => { + const { implementation } = getBackgroundEventsHandler; + + const getBackgroundEvents = jest.fn(); + const hasPermission = jest.fn().mockImplementation(() => false); + + const hooks = { + getBackgroundEvents, + hasPermission, + }; + + const engine = new JsonRpcEngine(); + + engine.push(createOriginMiddleware(MOCK_SNAP_ID)); + engine.push((request, response, next, end) => { + const result = implementation( + request as JsonRpcRequest, + response as PendingJsonRpcResponse, + next, + end, + hooks, + ); + + result?.catch(end); + }); + + const response = await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: 'snap_cancelBackgroundEvent', + params: { + id: 2, + }, + }); + + expect(response).toStrictEqual({ + error: { + code: 4100, + message: + 'The requested account and/or method has not been authorized by the user.', + stack: expect.any(String), + }, + id: 1, + jsonrpc: '2.0', + }); + }); + }); +}); diff --git a/packages/snaps-rpc-methods/src/permitted/getBackgroundEvents.ts b/packages/snaps-rpc-methods/src/permitted/getBackgroundEvents.ts new file mode 100644 index 0000000000..25b956c528 --- /dev/null +++ b/packages/snaps-rpc-methods/src/permitted/getBackgroundEvents.ts @@ -0,0 +1,68 @@ +import type { JsonRpcEngineEndCallback } from '@metamask/json-rpc-engine'; +import type { PermittedHandlerExport } from '@metamask/permission-controller'; +import { providerErrors } from '@metamask/rpc-errors'; +import type { + BackgroundEvent, + GetBackgroundEventsParams, + GetBackgroundEventsResult, + JsonRpcRequest, +} from '@metamask/snaps-sdk'; +import { type PendingJsonRpcResponse } from '@metamask/utils'; + +import { SnapEndowments } from '../endowments'; +import type { MethodHooksObject } from '../utils'; + +const methodName = 'snap_getBackgroundEvents'; + +const hookNames: MethodHooksObject = { + getBackgroundEvents: true, + hasPermission: true, +}; + +export type GetBackgroundEventsMethodHooks = { + getBackgroundEvents: () => BackgroundEvent[]; + hasPermission: (permissionName: string) => boolean; +}; + +export const getBackgroundEventsHandler: PermittedHandlerExport< + GetBackgroundEventsMethodHooks, + GetBackgroundEventsParams, + GetBackgroundEventsResult +> = { + methodNames: [methodName], + implementation: getGetBackgroundEventsImplementation, + hookNames, +}; + +/** + * The `snap_getBackgroundEvents` method implementation. + * + * @param _req - The JSON-RPC request object. Not used by this function. + * @param res - The JSON-RPC response object. + * @param _next - The `json-rpc-engine` "next" callback. + * Not used by this function. + * @param end - The `json-rpc-engine` "end" callback. + * @param hooks - The RPC method hooks. + * @param hooks.getBackgroundEvents - The function to get the background events. + * @param hooks.hasPermission - The function to check if a snap has the `endowment:cronjob` permission. + * @returns An array of background events. + */ +async function getGetBackgroundEventsImplementation( + _req: JsonRpcRequest, + res: PendingJsonRpcResponse, + _next: unknown, + end: JsonRpcEngineEndCallback, + { getBackgroundEvents, hasPermission }: GetBackgroundEventsMethodHooks, +): Promise { + if (!hasPermission(SnapEndowments.Cronjob)) { + return end(providerErrors.unauthorized()); + } + try { + const events = getBackgroundEvents(); + res.result = events; + } catch (error) { + return end(error); + } + + return end(); +} diff --git a/packages/snaps-rpc-methods/src/permitted/handlers.ts b/packages/snaps-rpc-methods/src/permitted/handlers.ts index 5e0c6f201d..cf8239f1a1 100644 --- a/packages/snaps-rpc-methods/src/permitted/handlers.ts +++ b/packages/snaps-rpc-methods/src/permitted/handlers.ts @@ -1,7 +1,9 @@ +import { cancelBackgroundEventHandler } from './cancelBackgroundEvent'; import { clearStateHandler } from './clearState'; import { createInterfaceHandler } from './createInterface'; import { providerRequestHandler } from './experimentalProviderRequest'; import { getAllSnapsHandler } from './getAllSnaps'; +import { getBackgroundEventsHandler } from './getBackgroundEvents'; import { getClientStatusHandler } from './getClientStatus'; import { getCurrencyRateHandler } from './getCurrencyRate'; import { getFileHandler } from './getFile'; @@ -13,6 +15,7 @@ import { invokeKeyringHandler } from './invokeKeyring'; import { invokeSnapSugarHandler } from './invokeSnapSugar'; import { requestSnapsHandler } from './requestSnaps'; import { resolveInterfaceHandler } from './resolveInterface'; +import { scheduleBackgroundEventHandler } from './scheduleBackgroundEvent'; import { setStateHandler } from './setState'; import { updateInterfaceHandler } from './updateInterface'; @@ -34,6 +37,9 @@ export const methodHandlers = { snap_resolveInterface: resolveInterfaceHandler, snap_getCurrencyRate: getCurrencyRateHandler, snap_experimentalProviderRequest: providerRequestHandler, + snap_scheduleBackgroundEvent: scheduleBackgroundEventHandler, + snap_cancelBackgroundEvent: cancelBackgroundEventHandler, + snap_getBackgroundEvents: getBackgroundEventsHandler, snap_setState: setStateHandler, }; /* eslint-enable @typescript-eslint/naming-convention */ diff --git a/packages/snaps-rpc-methods/src/permitted/index.ts b/packages/snaps-rpc-methods/src/permitted/index.ts index 47febaba09..8c9e2a71f2 100644 --- a/packages/snaps-rpc-methods/src/permitted/index.ts +++ b/packages/snaps-rpc-methods/src/permitted/index.ts @@ -1,7 +1,9 @@ +import type { CancelBackgroundEventMethodHooks } from './cancelBackgroundEvent'; import type { ClearStateHooks } from './clearState'; import type { CreateInterfaceMethodHooks } from './createInterface'; import type { ProviderRequestMethodHooks } from './experimentalProviderRequest'; import type { GetAllSnapsHooks } from './getAllSnaps'; +import type { GetBackgroundEventsMethodHooks } from './getBackgroundEvents'; import type { GetClientStatusHooks } from './getClientStatus'; import type { GetCurrencyRateMethodHooks } from './getCurrencyRate'; import type { GetInterfaceStateMethodHooks } from './getInterfaceState'; @@ -9,6 +11,7 @@ import type { GetSnapsHooks } from './getSnaps'; import type { GetStateHooks } from './getState'; import type { RequestSnapsHooks } from './requestSnaps'; import type { ResolveInterfaceMethodHooks } from './resolveInterface'; +import type { ScheduleBackgroundEventMethodHooks } from './scheduleBackgroundEvent'; import type { SetStateHooks } from './setState'; import type { UpdateInterfaceMethodHooks } from './updateInterface'; @@ -24,6 +27,9 @@ export type PermittedRpcMethodHooks = ClearStateHooks & ResolveInterfaceMethodHooks & GetCurrencyRateMethodHooks & ProviderRequestMethodHooks & + ScheduleBackgroundEventMethodHooks & + CancelBackgroundEventMethodHooks & + GetBackgroundEventsMethodHooks & SetStateHooks; export * from './handlers'; diff --git a/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.test.ts b/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.test.ts new file mode 100644 index 0000000000..a121ef8c1d --- /dev/null +++ b/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.test.ts @@ -0,0 +1,276 @@ +import { JsonRpcEngine } from '@metamask/json-rpc-engine'; +import type { + ScheduleBackgroundEventParams, + ScheduleBackgroundEventResult, +} from '@metamask/snaps-sdk'; +import { MOCK_SNAP_ID } from '@metamask/snaps-utils/test-utils'; +import type { JsonRpcRequest, PendingJsonRpcResponse } from '@metamask/utils'; + +import { scheduleBackgroundEventHandler } from './scheduleBackgroundEvent'; + +describe('snap_scheduleBackgroundEvent', () => { + describe('scheduleBackgroundEventHandler', () => { + it('has the expected shape', () => { + expect(scheduleBackgroundEventHandler).toMatchObject({ + methodNames: ['snap_scheduleBackgroundEvent'], + implementation: expect.any(Function), + hookNames: { + scheduleBackgroundEvent: true, + hasPermission: true, + }, + }); + }); + }); + + describe('implementation', () => { + const createOriginMiddleware = + (origin: string) => + (request: any, _response: unknown, next: () => void, _end: unknown) => { + request.origin = origin; + next(); + }; + + it('returns an id after calling the `scheduleBackgroundEvent` hook', async () => { + const { implementation } = scheduleBackgroundEventHandler; + + const scheduleBackgroundEvent = jest.fn().mockImplementation(() => 'foo'); + const hasPermission = jest.fn().mockImplementation(() => true); + + const hooks = { + scheduleBackgroundEvent, + hasPermission, + }; + + const engine = new JsonRpcEngine(); + + engine.push(createOriginMiddleware(MOCK_SNAP_ID)); + engine.push((request, response, next, end) => { + const result = implementation( + request as JsonRpcRequest, + response as PendingJsonRpcResponse, + next, + end, + hooks, + ); + + result?.catch(end); + }); + + const response = await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: 'snap_scheduleBackgroundEvent', + params: { + date: '2022-01-01T01:00Z', + request: { + method: 'handleExport', + params: ['p1'], + }, + }, + }); + + expect(response).toStrictEqual({ jsonrpc: '2.0', id: 1, result: 'foo' }); + }); + + it('schedules a background event with second precision', async () => { + const { implementation } = scheduleBackgroundEventHandler; + + const scheduleBackgroundEvent = jest.fn(); + const hasPermission = jest.fn().mockImplementation(() => true); + + const hooks = { + scheduleBackgroundEvent, + hasPermission, + }; + + const engine = new JsonRpcEngine(); + + engine.push(createOriginMiddleware(MOCK_SNAP_ID)); + engine.push((request, response, next, end) => { + const result = implementation( + request as JsonRpcRequest, + response as PendingJsonRpcResponse, + next, + end, + hooks, + ); + + result?.catch(end); + }); + + await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: 'snap_scheduleBackgroundEvent', + params: { + date: '2022-01-01T01:00:35.786+02:00', + request: { + method: 'handleExport', + params: ['p1'], + }, + }, + }); + + expect(scheduleBackgroundEvent).toHaveBeenCalledWith({ + date: '2022-01-01T01:00:35+02:00', + request: { + method: 'handleExport', + params: ['p1'], + }, + }); + }); + + it('throws if a snap does not have the "endowment:cronjob" permission', async () => { + const { implementation } = scheduleBackgroundEventHandler; + + const scheduleBackgroundEvent = jest.fn(); + const hasPermission = jest.fn().mockImplementation(() => false); + + const hooks = { + scheduleBackgroundEvent, + hasPermission, + }; + + const engine = new JsonRpcEngine(); + + engine.push(createOriginMiddleware(MOCK_SNAP_ID)); + engine.push((request, response, next, end) => { + const result = implementation( + request as JsonRpcRequest, + response as PendingJsonRpcResponse, + next, + end, + hooks, + ); + + result?.catch(end); + }); + + const response = await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: 'snap_scheduleBackgroundEvent', + params: { + date: '2022-01-01T01:00Z', + request: { + method: 'handleExport', + params: ['p1'], + }, + }, + }); + + expect(response).toStrictEqual({ + error: { + code: 4100, + message: + 'The requested account and/or method has not been authorized by the user.', + stack: expect.any(String), + }, + id: 1, + jsonrpc: '2.0', + }); + }); + + it('throws if no timezone information is provided in the ISO8601 string', async () => { + const { implementation } = scheduleBackgroundEventHandler; + + const scheduleBackgroundEvent = jest.fn(); + const hasPermission = jest.fn().mockImplementation(() => true); + + const hooks = { + scheduleBackgroundEvent, + hasPermission, + }; + + const engine = new JsonRpcEngine(); + + engine.push(createOriginMiddleware(MOCK_SNAP_ID)); + engine.push((request, response, next, end) => { + const result = implementation( + request as JsonRpcRequest, + response as PendingJsonRpcResponse, + next, + end, + hooks, + ); + + result?.catch(end); + }); + + const response = await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: 'snap_scheduleBackgroundEvent', + params: { + date: '2022-01-01T01:00', + request: { + method: 'handleExport', + params: ['p1'], + }, + }, + }); + + expect(response).toStrictEqual({ + error: { + code: -32602, + message: + 'Invalid params: At path: date -- ISO 8601 string must have timezone information.', + stack: expect.any(String), + }, + id: 1, + jsonrpc: '2.0', + }); + }); + + it('throws on invalid params', async () => { + const { implementation } = scheduleBackgroundEventHandler; + + const scheduleBackgroundEvent = jest.fn(); + const hasPermission = jest.fn().mockImplementation(() => true); + + const hooks = { + scheduleBackgroundEvent, + hasPermission, + }; + + const engine = new JsonRpcEngine(); + + engine.push(createOriginMiddleware(MOCK_SNAP_ID)); + engine.push((request, response, next, end) => { + const result = implementation( + request as JsonRpcRequest, + response as PendingJsonRpcResponse, + next, + end, + hooks, + ); + + result?.catch(end); + }); + + const response = await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: 'snap_scheduleBackgroundEvent', + params: { + date: 'foobar', + request: { + method: 'handleExport', + params: ['p1'], + }, + }, + }); + + expect(response).toStrictEqual({ + error: { + code: -32602, + message: + 'Invalid params: At path: date -- Not a valid ISO 8601 string.', + stack: expect.any(String), + }, + id: 1, + jsonrpc: '2.0', + }); + }); + }); +}); diff --git a/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.ts b/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.ts new file mode 100644 index 0000000000..99e7ce2813 --- /dev/null +++ b/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.ts @@ -0,0 +1,151 @@ +import type { JsonRpcEngineEndCallback } from '@metamask/json-rpc-engine'; +import type { PermittedHandlerExport } from '@metamask/permission-controller'; +import { providerErrors, rpcErrors } from '@metamask/rpc-errors'; +import type { + JsonRpcRequest, + ScheduleBackgroundEventParams, + ScheduleBackgroundEventResult, +} from '@metamask/snaps-sdk'; +import type { CronjobRpcRequest } from '@metamask/snaps-utils'; +import { + CronjobRpcRequestStruct, + type InferMatching, +} from '@metamask/snaps-utils'; +import { + StructError, + create, + object, + refine, + string, +} from '@metamask/superstruct'; +import { assert, type PendingJsonRpcResponse } from '@metamask/utils'; +import { DateTime } from 'luxon'; + +import { SnapEndowments } from '../endowments'; +import type { MethodHooksObject } from '../utils'; + +const methodName = 'snap_scheduleBackgroundEvent'; + +const hookNames: MethodHooksObject = { + scheduleBackgroundEvent: true, + hasPermission: true, +}; + +type ScheduleBackgroundEventHookParams = { + date: string; + request: CronjobRpcRequest; +}; + +export type ScheduleBackgroundEventMethodHooks = { + scheduleBackgroundEvent: ( + snapEvent: ScheduleBackgroundEventHookParams, + ) => string; + + hasPermission: (permissionName: string) => boolean; +}; + +export const scheduleBackgroundEventHandler: PermittedHandlerExport< + ScheduleBackgroundEventMethodHooks, + ScheduleBackgroundEventParameters, + ScheduleBackgroundEventResult +> = { + methodNames: [methodName], + implementation: getScheduleBackgroundEventImplementation, + hookNames, +}; + +const offsetRegex = /Z|([+-]\d{2}:?\d{2})$/u; +const ScheduleBackgroundEventsParametersStruct = object({ + date: refine(string(), 'date', (val) => { + const date = DateTime.fromISO(val); + if (date.isValid) { + // Luxon doesn't have a reliable way to check if timezone info was not provided + if (!offsetRegex.test(val)) { + return 'ISO 8601 string must have timezone information'; + } + return true; + } + return 'Not a valid ISO 8601 string'; + }), + request: CronjobRpcRequestStruct, +}); + +export type ScheduleBackgroundEventParameters = InferMatching< + typeof ScheduleBackgroundEventsParametersStruct, + ScheduleBackgroundEventParams +>; + +/** + * The `snap_scheduleBackgroundEvent` method implementation. + * + * @param req - The JSON-RPC request object. + * @param res - The JSON-RPC response object. + * @param _next - The `json-rpc-engine` "next" callback. Not used by this + * function. + * @param end - The `json-rpc-engine` "end" callback. + * @param hooks - The RPC method hooks. + * @param hooks.scheduleBackgroundEvent - The function to schedule a background event. + * @param hooks.hasPermission - The function to check if a snap has the `endowment:cronjob` permission. + * @returns An id representing the background event. + */ +async function getScheduleBackgroundEventImplementation( + req: JsonRpcRequest, + res: PendingJsonRpcResponse, + _next: unknown, + end: JsonRpcEngineEndCallback, + { + scheduleBackgroundEvent, + hasPermission, + }: ScheduleBackgroundEventMethodHooks, +): Promise { + const { params } = req; + + if (!hasPermission(SnapEndowments.Cronjob)) { + return end(providerErrors.unauthorized()); + } + + try { + const validatedParams = getValidatedParams(params); + + const { date, request } = validatedParams; + + // Make sure any millisecond precision is removed. + const truncatedDate = DateTime.fromISO(date, { setZone: true }) + .startOf('second') + .toISO({ + suppressMilliseconds: true, + }); + + assert(truncatedDate); + + const id = scheduleBackgroundEvent({ date: truncatedDate, request }); + res.result = id; + } catch (error) { + return end(error); + } + + return end(); +} + +/** + * Validate the scheduleBackgroundEvent method `params` and returns them cast to the correct + * type. Throws if validation fails. + * + * @param params - The unvalidated params object from the method request. + * @returns The validated resolveInterface method parameter object. + */ +function getValidatedParams( + params: unknown, +): ScheduleBackgroundEventParameters { + try { + return create(params, ScheduleBackgroundEventsParametersStruct); + } catch (error) { + if (error instanceof StructError) { + throw rpcErrors.invalidParams({ + message: `Invalid params: ${error.message}.`, + }); + } + /* istanbul ignore next */ + throw rpcErrors.internal(); + } +} diff --git a/packages/snaps-sdk/src/types/methods/cancel-background-event.ts b/packages/snaps-sdk/src/types/methods/cancel-background-event.ts new file mode 100644 index 0000000000..c0552293f9 --- /dev/null +++ b/packages/snaps-sdk/src/types/methods/cancel-background-event.ts @@ -0,0 +1,15 @@ +/** + * The request parameters for the `snap_cancelBackgroundEvent` method. + * + * @property id - The id of the background event to cancel. + */ +export type CancelBackgroundEventParams = { + id: string; +}; + +/** + * The result returned for the `snap_cancelBackgroundEvent` method. + * + * This method does not return anything. + */ +export type CancelBackgroundEventResult = null; diff --git a/packages/snaps-sdk/src/types/methods/get-background-events.ts b/packages/snaps-sdk/src/types/methods/get-background-events.ts new file mode 100644 index 0000000000..84fd6dfcff --- /dev/null +++ b/packages/snaps-sdk/src/types/methods/get-background-events.ts @@ -0,0 +1,39 @@ +import type { Json } from '@metamask/utils'; + +import type { SnapId } from '../snap'; + +/** + * Background event type + * + * @property id - The unique id representing the event. + * @property scheduledAt - The ISO 8601 time stamp of when the event was scheduled. + * @property snapId - The id of the snap that scheduled the event. + * @property date - The ISO 8601 date of when the event is scheduled for. + * @property request - The request that is supplied to the `onCronjob` handler when the event is fired. + */ +export type BackgroundEvent = { + id: string; + scheduledAt: string; + snapId: SnapId; + date: string; + request: { + method: string; + jsonrpc?: '2.0' | undefined; + id?: string | number | null | undefined; + params?: Json[] | Record | undefined; + }; +}; + +/** + * The result returned by the `snap_getBackgroundEvents` method. + * + * It consists of an array background events (if any) for a snap. + */ +export type GetBackgroundEventsResult = BackgroundEvent[]; + +/** + * The request parameters for the `snap_getBackgroundEvents` method. + * + * This method does not accept any parameters. + */ +export type GetBackgroundEventsParams = never; diff --git a/packages/snaps-sdk/src/types/methods/index.ts b/packages/snaps-sdk/src/types/methods/index.ts index 70bd559285..c8a637038f 100644 --- a/packages/snaps-sdk/src/types/methods/index.ts +++ b/packages/snaps-sdk/src/types/methods/index.ts @@ -24,4 +24,7 @@ export * from './provider-request'; export * from './request-snaps'; export * from './update-interface'; export * from './resolve-interface'; +export * from './schedule-background-event'; +export * from './cancel-background-event'; +export * from './get-background-events'; export * from './set-state'; diff --git a/packages/snaps-sdk/src/types/methods/methods.ts b/packages/snaps-sdk/src/types/methods/methods.ts index 21386d0fd8..f73cb07090 100644 --- a/packages/snaps-sdk/src/types/methods/methods.ts +++ b/packages/snaps-sdk/src/types/methods/methods.ts @@ -1,10 +1,18 @@ import type { Method } from '../../internals'; +import type { + CancelBackgroundEventParams, + CancelBackgroundEventResult, +} from './cancel-background-event'; import type { ClearStateParams, ClearStateResult } from './clear-state'; import type { CreateInterfaceParams, CreateInterfaceResult, } from './create-interface'; import type { DialogParams, DialogResult } from './dialog'; +import type { + GetBackgroundEventsParams, + GetBackgroundEventsResult, +} from './get-background-events'; import type { GetBip32EntropyParams, GetBip32EntropyResult, @@ -58,6 +66,10 @@ import type { ResolveInterfaceParams, ResolveInterfaceResult, } from './resolve-interface'; +import type { + ScheduleBackgroundEventParams, + ScheduleBackgroundEventResult, +} from './schedule-background-event'; import type { SetStateParams, SetStateResult } from './set-state'; import type { UpdateInterfaceParams, @@ -85,6 +97,18 @@ export type SnapMethods = { snap_manageAccounts: [ManageAccountsParams, ManageAccountsResult]; snap_manageState: [ManageStateParams, ManageStateResult]; snap_notify: [NotifyParams, NotifyResult]; + snap_scheduleBackgroundEvent: [ + ScheduleBackgroundEventParams, + ScheduleBackgroundEventResult, + ]; + snap_cancelBackgroundEvent: [ + CancelBackgroundEventParams, + CancelBackgroundEventResult, + ]; + snap_getBackgroundEvents: [ + GetBackgroundEventsParams, + GetBackgroundEventsResult, + ]; snap_createInterface: [CreateInterfaceParams, CreateInterfaceResult]; snap_updateInterface: [UpdateInterfaceParams, UpdateInterfaceResult]; snap_getInterfaceState: [GetInterfaceStateParams, GetInterfaceStateResult]; diff --git a/packages/snaps-sdk/src/types/methods/schedule-background-event.ts b/packages/snaps-sdk/src/types/methods/schedule-background-event.ts new file mode 100644 index 0000000000..caa58e3390 --- /dev/null +++ b/packages/snaps-sdk/src/types/methods/schedule-background-event.ts @@ -0,0 +1,17 @@ +import type { Cronjob } from '../permissions'; + +/** + * The request parameters for the `snap_scheduleBackgroundEvent` method. + * + * @property date - The ISO8601 date of when to fire the background event. + * @property request - The request to be called when the event fires. + */ +export type ScheduleBackgroundEventParams = { + date: string; + request: Cronjob['request']; +}; + +/** + * The result returned by the `snap_scheduleBackgroundEvent` method, which is the ID of the scheduled event. + */ +export type ScheduleBackgroundEventResult = string; diff --git a/packages/snaps-simulation/src/methods/specifications.test.ts b/packages/snaps-simulation/src/methods/specifications.test.ts index 9b804b4586..2690485057 100644 --- a/packages/snaps-simulation/src/methods/specifications.test.ts +++ b/packages/snaps-simulation/src/methods/specifications.test.ts @@ -59,6 +59,7 @@ describe('getPermissionSpecifications', () => { "snap", ], "targetName": "endowment:cronjob", + "validator": [Function], }, "endowment:ethereum-provider": { "allowedCaveats": null, diff --git a/packages/test-snaps/src/features/snaps/cronjobs/Cronjobs.tsx b/packages/test-snaps/src/features/snaps/cronjobs/Cronjobs.tsx index 4bf6e8bbd6..e5e2bb273b 100644 --- a/packages/test-snaps/src/features/snaps/cronjobs/Cronjobs.tsx +++ b/packages/test-snaps/src/features/snaps/cronjobs/Cronjobs.tsx @@ -1,6 +1,11 @@ import type { FunctionComponent } from 'react'; import { Snap } from '../../../components'; +import { + CancelBackgroundEvent, + ScheduleBackgroundEvent, + GetBackgroundEvents, +} from './components'; import { CRONJOBS_SNAP_ID, CRONJOBS_SNAP_PORT, @@ -15,6 +20,10 @@ export const Cronjobs: FunctionComponent = () => { port={CRONJOBS_SNAP_PORT} version={CRONJOBS_VERSION} testId="cronjobs" - > + > + + + + ); }; diff --git a/packages/test-snaps/src/features/snaps/cronjobs/components/CancelBackgroundEvent.tsx b/packages/test-snaps/src/features/snaps/cronjobs/components/CancelBackgroundEvent.tsx new file mode 100644 index 0000000000..dd31e85a85 --- /dev/null +++ b/packages/test-snaps/src/features/snaps/cronjobs/components/CancelBackgroundEvent.tsx @@ -0,0 +1,52 @@ +import { logError } from '@metamask/snaps-utils'; +import type { ChangeEvent, FormEvent, FunctionComponent } from 'react'; +import { useState } from 'react'; +import { Button, Form } from 'react-bootstrap'; + +import { useInvokeMutation } from '../../../../api'; +import { getSnapId } from '../../../../utils'; +import { CRONJOBS_SNAP_PORT, CRONJOBS_SNAP_ID } from '../constants'; + +export const CancelBackgroundEvent: FunctionComponent = () => { + const [id, setId] = useState(''); + const [invokeSnap, { isLoading }] = useInvokeMutation(); + + const handleChange = (event: ChangeEvent) => { + setId(event.target.value); + }; + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + invokeSnap({ + snapId: getSnapId(CRONJOBS_SNAP_ID, CRONJOBS_SNAP_PORT), + method: 'cancelNotification', + params: { + id, + }, + }).catch(logError); + }; + + return ( + <> +
+ + + Background event id + + + + + +
+ + ); +}; diff --git a/packages/test-snaps/src/features/snaps/cronjobs/components/GetBackgroundEvents.tsx b/packages/test-snaps/src/features/snaps/cronjobs/components/GetBackgroundEvents.tsx new file mode 100644 index 0000000000..041a817efc --- /dev/null +++ b/packages/test-snaps/src/features/snaps/cronjobs/components/GetBackgroundEvents.tsx @@ -0,0 +1,39 @@ +import { logError } from '@metamask/snaps-utils'; +import type { FunctionComponent } from 'react'; +import { Button } from 'react-bootstrap'; + +import { useInvokeMutation } from '../../../../api'; +import { Result } from '../../../../components'; +import { getSnapId } from '../../../../utils'; +import { CRONJOBS_SNAP_PORT, CRONJOBS_SNAP_ID } from '../constants'; + +export const GetBackgroundEvents: FunctionComponent = () => { + const [invokeSnap, { isLoading, data, error }] = useInvokeMutation(); + + const handleClick = () => { + invokeSnap({ + snapId: getSnapId(CRONJOBS_SNAP_ID, CRONJOBS_SNAP_PORT), + method: 'getBackgroundEvents', + }).catch(logError); + }; + + return ( + <> + + + + + {JSON.stringify(data, null, 2)} + {JSON.stringify(error, null, 2)} + + + + ); +}; diff --git a/packages/test-snaps/src/features/snaps/cronjobs/components/ScheduleBackgroundEvent.tsx b/packages/test-snaps/src/features/snaps/cronjobs/components/ScheduleBackgroundEvent.tsx new file mode 100644 index 0000000000..8eb11d7c81 --- /dev/null +++ b/packages/test-snaps/src/features/snaps/cronjobs/components/ScheduleBackgroundEvent.tsx @@ -0,0 +1,59 @@ +import { logError } from '@metamask/snaps-utils'; +import type { ChangeEvent, FormEvent, FunctionComponent } from 'react'; +import { useState } from 'react'; +import { Button, Form } from 'react-bootstrap'; + +import { useInvokeMutation } from '../../../../api'; +import { Result } from '../../../../components'; +import { getSnapId } from '../../../../utils'; +import { CRONJOBS_SNAP_PORT, CRONJOBS_SNAP_ID } from '../constants'; + +export const ScheduleBackgroundEvent: FunctionComponent = () => { + const [date, setDate] = useState(''); + const [invokeSnap, { isLoading, data, error }] = useInvokeMutation(); + + const handleChange = (event: ChangeEvent) => { + setDate(event.target.value); + }; + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + invokeSnap({ + snapId: getSnapId(CRONJOBS_SNAP_ID, CRONJOBS_SNAP_PORT), + method: 'scheduleNotification', + params: { + date, + }, + }).catch(logError); + }; + + return ( + <> +
+ + Date (must be in IS8601 format) + + + + +
+ +

Background event id

+ + + {JSON.stringify(data, null, 2)} + {JSON.stringify(error, null, 2)} + + + + ); +}; diff --git a/packages/test-snaps/src/features/snaps/cronjobs/components/index.ts b/packages/test-snaps/src/features/snaps/cronjobs/components/index.ts new file mode 100644 index 0000000000..03bef54e84 --- /dev/null +++ b/packages/test-snaps/src/features/snaps/cronjobs/components/index.ts @@ -0,0 +1,3 @@ +export * from './CancelBackgroundEvent'; +export * from './GetBackgroundEvents'; +export * from './ScheduleBackgroundEvent'; diff --git a/yarn.lock b/yarn.lock index 0b7a429e29..bca4300f8c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5736,6 +5736,7 @@ __metadata: "@types/concat-stream": "npm:^2.0.0" "@types/gunzip-maybe": "npm:^1.4.0" "@types/jest": "npm:^27.5.1" + "@types/luxon": "npm:^3" "@types/mocha": "npm:^10.0.1" "@types/node": "npm:18.14.2" "@types/readable-stream": "npm:^4.0.15" @@ -5773,6 +5774,7 @@ __metadata: jest: "npm:^29.0.2" jest-fetch-mock: "npm:^3.0.3" jest-silent-reporter: "npm:^0.6.0" + luxon: "npm:^3.5.0" mkdirp: "npm:^1.0.4" nanoid: "npm:^3.1.31" prettier: "npm:^2.8.8" @@ -6003,6 +6005,7 @@ __metadata: "@swc/core": "npm:1.3.78" "@swc/jest": "npm:^0.2.26" "@ts-bridge/cli": "npm:^0.6.1" + "@types/luxon": "npm:^3" "@types/node": "npm:18.14.2" "@typescript-eslint/eslint-plugin": "npm:^5.42.1" "@typescript-eslint/parser": "npm:^6.21.0" @@ -6019,6 +6022,7 @@ __metadata: jest: "npm:^29.0.2" jest-it-up: "npm:^2.0.0" jest-silent-reporter: "npm:^0.6.0" + luxon: "npm:^3.5.0" prettier: "npm:^2.8.8" prettier-plugin-packagejson: "npm:^2.5.2" typescript: "npm:~5.3.3" @@ -7924,6 +7928,13 @@ __metadata: languageName: node linkType: hard +"@types/luxon@npm:^3": + version: 3.4.2 + resolution: "@types/luxon@npm:3.4.2" + checksum: 10/fd89566e3026559f2bc4ddcc1e70a2c16161905ed50be9473ec0cfbbbe919165041408c4f6e06c4bcf095445535052e2c099087c76b1b38e368127e618fc968d + languageName: node + linkType: hard + "@types/mime@npm:*, @types/mime@npm:^3.0.0": version: 3.0.4 resolution: "@types/mime@npm:3.0.4" @@ -17239,6 +17250,13 @@ __metadata: languageName: node linkType: hard +"luxon@npm:^3.5.0": + version: 3.5.0 + resolution: "luxon@npm:3.5.0" + checksum: 10/48f86e6c1c96815139f8559456a3354a276ba79bcef0ae0d4f2172f7652f3ba2be2237b0e103b8ea0b79b47715354ac9fac04eb1db3485dcc72d5110491dd47f + languageName: node + linkType: hard + "luxon@patch:luxon@npm%3A3.3.0#./.yarn/patches/luxon-npm-3.3.0-bdbae9bfd5.patch::locator=root%40workspace%3A.": version: 3.3.0 resolution: "luxon@patch:luxon@npm%3A3.3.0#./.yarn/patches/luxon-npm-3.3.0-bdbae9bfd5.patch::version=3.3.0&hash=b12ba2&locator=root%40workspace%3A."