From e3f6fb9a4fd377c4e3672d4c9a0445babc0bfe09 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Tue, 3 Dec 2024 09:58:03 -0500 Subject: [PATCH 01/13] add background event method files --- .../src/restricted/cancelBackground.test.ts | 0 .../src/restricted/cancelBackgroundEvent.ts | 0 .../src/restricted/scheduleBackground.test.ts | 0 .../src/restricted/scheduleBackgroundEvent.ts | 32 +++++++++++++++++++ 4 files changed, 32 insertions(+) create mode 100644 packages/snaps-rpc-methods/src/restricted/cancelBackground.test.ts create mode 100644 packages/snaps-rpc-methods/src/restricted/cancelBackgroundEvent.ts create mode 100644 packages/snaps-rpc-methods/src/restricted/scheduleBackground.test.ts create mode 100644 packages/snaps-rpc-methods/src/restricted/scheduleBackgroundEvent.ts diff --git a/packages/snaps-rpc-methods/src/restricted/cancelBackground.test.ts b/packages/snaps-rpc-methods/src/restricted/cancelBackground.test.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/snaps-rpc-methods/src/restricted/cancelBackgroundEvent.ts b/packages/snaps-rpc-methods/src/restricted/cancelBackgroundEvent.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/snaps-rpc-methods/src/restricted/scheduleBackground.test.ts b/packages/snaps-rpc-methods/src/restricted/scheduleBackground.test.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/snaps-rpc-methods/src/restricted/scheduleBackgroundEvent.ts b/packages/snaps-rpc-methods/src/restricted/scheduleBackgroundEvent.ts new file mode 100644 index 0000000000..b51a09f4ac --- /dev/null +++ b/packages/snaps-rpc-methods/src/restricted/scheduleBackgroundEvent.ts @@ -0,0 +1,32 @@ +const methodName = 'snap_scheduleBackgroundEvent'; + +/** + * The specification builder for the `snap_dialog` permission. `snap_dialog` + * lets the Snap display one of the following dialogs to the user: + * - An alert, for displaying information. + * - A confirmation, for accepting or rejecting some action. + * - A prompt, for inputting some information. + * + * @param options - The specification builder options. + * @param options.allowedCaveats - The optional allowed caveats for the + * permission. + * @param options.methodHooks - The RPC method hooks needed by the method + * implementation. + * @returns The specification for the `snap_dialog` permission. + */ +const specificationBuilder: PermissionSpecificationBuilder< + PermissionType.RestrictedMethod, + DialogSpecificationBuilderOptions, + DialogSpecification +> = ({ + allowedCaveats = null, + methodHooks, +}: DialogSpecificationBuilderOptions) => { + return { + permissionType: PermissionType.RestrictedMethod, + targetName: methodName, + allowedCaveats, + methodImplementation: getDialogImplementation(methodHooks), + subjectTypes: [SubjectType.Snap], + }; +}; \ No newline at end of file From 7442725e1889b9a13f53e267988f33e0c75e8ed8 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Tue, 10 Dec 2024 18:33:34 -0500 Subject: [PATCH 02/13] add background events feature --- package.json | 3 + packages/snaps-controllers/package.json | 3 +- .../src/cronjob/CronjobController.test.ts | 320 +++++++++++++++++- .../src/cronjob/CronjobController.ts | 294 ++++++++++++++-- .../src/test-utils/controller.ts | 2 + packages/snaps-rpc-methods/jest.config.js | 8 +- packages/snaps-rpc-methods/package.json | 4 +- .../permitted/cancelBackgroundEvent.test.ts | 138 ++++++++ .../src/permitted/cancelBackgroundEvent.ts | 98 ++++++ .../src/permitted/getBackgroundEvents.test.ts | 105 ++++++ .../src/permitted/getBackgroundEvents.ts | 61 ++++ .../src/permitted/handlers.ts | 6 + .../snaps-rpc-methods/src/permitted/index.ts | 8 +- .../src/permitted/scheduleBackground.test.ts | 157 +++++++++ .../src/permitted/scheduleBackgroundEvent.ts | 128 +++++++ .../src/restricted/cancelBackground.test.ts | 0 .../src/restricted/cancelBackgroundEvent.ts | 0 .../src/restricted/scheduleBackground.test.ts | 0 .../src/restricted/scheduleBackgroundEvent.ts | 32 -- .../types/methods/cancel-background-event.ts | 5 + .../types/methods/get-background-events.ts | 18 + packages/snaps-sdk/src/types/methods/index.ts | 3 + .../methods/schedule-background-event.ts | 8 + yarn.lock | 29 ++ 24 files changed, 1371 insertions(+), 59 deletions(-) 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/scheduleBackground.test.ts create mode 100644 packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.ts delete mode 100644 packages/snaps-rpc-methods/src/restricted/cancelBackground.test.ts delete mode 100644 packages/snaps-rpc-methods/src/restricted/cancelBackgroundEvent.ts delete mode 100644 packages/snaps-rpc-methods/src/restricted/scheduleBackground.test.ts delete mode 100644 packages/snaps-rpc-methods/src/restricted/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 diff --git a/package.json b/package.json index 97019f8f24..e1018b6f05 100644 --- a/package.json +++ b/package.json @@ -84,6 +84,7 @@ "@ts-bridge/cli": "^0.6.1", "@types/jest": "^27.5.1", "@types/lodash": "^4", + "@types/luxon": "^3", "@types/node": "18.14.2", "@typescript-eslint/eslint-plugin": "^5.42.1", "@typescript-eslint/parser": "^6.21.0", @@ -105,6 +106,7 @@ "jest-silent-reporter": "^0.6.0", "lint-staged": "^12.4.1", "lodash": "^4.17.21", + "luxon": "^3.5.0", "minimatch": "^7.4.1", "prettier": "^2.8.8", "prettier-plugin-packagejson": "^2.5.2", @@ -114,6 +116,7 @@ "ts-node": "^10.9.1", "tsx": "^4.19.1", "typescript": "~5.3.3", + "uuid": "^11.0.3", "vite": "^4.3.9" }, "packageManager": "yarn@4.4.1", diff --git a/packages/snaps-controllers/package.json b/packages/snaps-controllers/package.json index e61ac3af2c..b839fbcc1d 100644 --- a/packages/snaps-controllers/package.json +++ b/packages/snaps-controllers/package.json @@ -104,7 +104,8 @@ "readable-stream": "^3.6.2", "readable-web-to-node-stream": "^3.0.2", "semver": "^7.5.4", - "tar-stream": "^3.1.7" + "tar-stream": "^3.1.7", + "uuid": "^11.0.3" }, "devDependencies": { "@esbuild-plugins/node-globals-polyfill": "^0.2.3", diff --git a/packages/snaps-controllers/src/cronjob/CronjobController.test.ts b/packages/snaps-controllers/src/cronjob/CronjobController.test.ts index a33f8ed9e7..eb16615bff 100644 --- a/packages/snaps-controllers/src/cronjob/CronjobController.test.ts +++ b/packages/snaps-controllers/src/cronjob/CronjobController.test.ts @@ -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,177 @@ 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:00', + 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('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:00', + request: { + method: 'handleEvent', + params: ['p1'], + }, + }; + + const id = cronjobController.scheduleBackgroundEvent(backgroundEvent); + + expect(cronjobController.state.events).toStrictEqual({ + [id]: { id, scheduledAt: expect.any(String), ...backgroundEvent }, + }); + + cronjobController.cancelBackgroundEvent(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("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:00', + 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:00', + 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:00', + 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 +464,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:00', + request: { + method: 'handleEvent', + params: ['p1'], + }, + }, + bar: { + id: 'bar', + scheduledAt: new Date().toISOString(), + snapId: MOCK_SNAP_ID, + date: '2021-01-01T01:00', + request: { + method: 'handleEvent', + params: ['p1'], + }, + }, + }, + }, }); const snapInfo: TruncatedSnap = { @@ -303,7 +501,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:00', + request: { + method: 'handleEvent', + params: ['p1'], + }, + }, + }); + + jest.advanceTimersByTime(inMilliseconds(1, Duration.Day)); expect(rootMessenger.call).toHaveBeenNthCalledWith( 4, @@ -319,6 +530,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 +563,15 @@ describe('CronjobController', () => { version: MOCK_VERSION, }; + cronjobController.scheduleBackgroundEvent({ + snapId: MOCK_SNAP_ID, + date: '2022-01-01T01:00', + request: { + method: 'handleEvent', + params: ['p1'], + }, + }); + rootMessenger.publish( 'SnapController:snapInstalled', snapInfo, @@ -362,6 +595,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 +632,15 @@ describe('CronjobController', () => { version: MOCK_VERSION, }; + const id = cronjobController.scheduleBackgroundEvent({ + snapId: MOCK_SNAP_ID, + date: '2022-01-01T01:00', + request: { + method: 'handleEvent', + params: ['p1'], + }, + }); + rootMessenger.publish( 'SnapController:snapInstalled', snapInfo, @@ -405,6 +664,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:00', + request: { + method: 'handleEvent', + params: ['p1'], + }, + }, + }); + cronjobController.destroy(); }); @@ -415,6 +702,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:00', + request: { + method: 'handleEvent', + params: ['p1'], + }, + }, + }, + }, }); const snapInfo: TruncatedSnap = { @@ -438,6 +740,8 @@ describe('CronjobController', () => { MOCK_ORIGIN, ); + expect(cronjobController.state.events).toStrictEqual({}); + jest.advanceTimersByTime(inMilliseconds(15, Duration.Minute)); expect(rootMessenger.call).toHaveBeenNthCalledWith( @@ -454,6 +758,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(); }); diff --git a/packages/snaps-controllers/src/cronjob/CronjobController.ts b/packages/snaps-controllers/src/cronjob/CronjobController.ts index af01edab9e..ce2333db7e 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, @@ -19,7 +19,9 @@ import { parseCronExpression, logError, } from '@metamask/snaps-utils'; -import { Duration, inMilliseconds } from '@metamask/utils'; +import { assert, Duration, hasProperty, inMilliseconds } from '@metamask/utils'; +import { castDraft } from 'immer'; +import { v4 as uuid } from 'uuid'; import type { GetAllSnaps, @@ -41,11 +43,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,8 +106,11 @@ export type StoredJobInformation = { export type CronjobControllerState = { jobs: Record; + events: Record; }; +const subscriptionMap = new WeakMap(); + const controllerName = 'CronjobController'; /** @@ -112,10 +136,12 @@ export class CronjobController extends BaseController< messenger, metadata: { jobs: { persist: true, anonymous: false }, + events: { persist: true, anonymous: false }, }, name: controllerName, state: { jobs: {}, + events: {}, ...state, }, }); @@ -130,35 +156,82 @@ export class CronjobController extends BaseController< // Subscribe to Snap events /* eslint-disable @typescript-eslint/unbound-method */ + + subscriptionMap.set(this, new Map()); + + const map = subscriptionMap.get(this); + + map.set( + 'SnapController:snapInstalled', + this._handleSnapRegisterEvent.bind(this), + ); + map.set( + 'SnapController:snapEnabled', + this._handleSnapEnabledEvent.bind(this), + ); + map.set( + 'SnapController:snapUninstalled', + this._handleSnapUnregisterEvent.bind(this), + ); + map.set( + 'SnapController:snapDisabled', + this._handleSnapDisabledEvent.bind(this), + ); + map.set( + 'SnapController:snapUpdated', + this._handleEventSnapUpdated.bind(this), + ); + this.messagingSystem.subscribe( 'SnapController:snapInstalled', - this._handleSnapRegisterEvent, + map.get('SnapController:snapInstalled'), ); this.messagingSystem.subscribe( 'SnapController:snapUninstalled', - this._handleSnapUnregisterEvent, + map.get('SnapController:snapUninstalled'), ); this.messagingSystem.subscribe( 'SnapController:snapEnabled', - this._handleSnapRegisterEvent, + map.get('SnapController:snapEnabled'), ); this.messagingSystem.subscribe( 'SnapController:snapDisabled', - this._handleSnapUnregisterEvent, + map.get('SnapController:snapDisabled'), ); this.messagingSystem.subscribe( 'SnapController:snapUpdated', - this._handleEventSnapUpdated, + map.get('SnapController:snapUpdated'), ); /* 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)).catch( + (error) => { + logError(error); + }, + ); } /** @@ -267,11 +340,127 @@ 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, + ) { + const event = this.getBackgroundEventWithId(backgroundEventWithoutId); + event.scheduledAt = new Date().toISOString(); + this.setUpBackgroundEvent(event); + this.update((state) => { + state.events[event.id] = castDraft(event); + }); + + return event.id; + } + + /** + * Cancel a background event. + * + * @param id - The id of the background event to cancel. + * @throws If the event does not exist. + */ + cancelBackgroundEvent(id: string) { + assert( + this.state.events[id], + `A background event with the id of "${id}" does not exist.`, + ); + + const timer = this.#timers.get(id); + timer?.cancel(); + this.#timers.delete(id); + this.#snapIds.delete(id); + this.update((state) => { + delete state.events[id]; + }); + } + + /** + * Assign an id to a background event. + * + * @param backgroundEventWithoutId - A background event with an unassigned id. + * @returns A background event with an id. + */ + private getBackgroundEventWithId( + backgroundEventWithoutId: Omit, + ): BackgroundEvent { + assert( + !hasProperty(backgroundEventWithoutId, 'id'), + `Background event already has an id: ${ + (backgroundEventWithoutId as BackgroundEvent).id + }`, + ); + const event = backgroundEventWithoutId as BackgroundEvent; + const id = this.generateBackgroundEventId(); + event.id = id; + return event; + } + + /** + * A helper function to handle setup of the background event. + * + * @param event - A background event. + */ + private setUpBackgroundEvent(event: BackgroundEvent) { + const date = new Date(event.date); + const now = new Date(); + const ms = date.getTime() - now.getTime(); + + const timer = new Timer(ms); + timer.start(() => { + this.executeBackgroundEvent(event).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); + } + + /** + * Fire the background event. + * + * @param event - A background event. + */ + private async executeBackgroundEvent(event: BackgroundEvent) { + await this.#messenger.call('SnapController:handleRequest', { + snapId: event.snapId, + origin: '', + handler: HandlerType.OnCronjob, + request: event.request, + }); + } + + /** + * 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: string): 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: string, skipEvents = false) { const jobs = [...this.#snapIds.entries()].filter( ([_, jobSnapId]) => jobSnapId === snapId, ); @@ -283,6 +472,11 @@ export class CronjobController extends BaseController< timer.cancel(); this.#timers.delete(id); this.#snapIds.delete(id); + if (!skipEvents && this.state.events[id]) { + this.update((state) => { + delete state.events[id]; + }); + } } }); } @@ -302,6 +496,19 @@ export class CronjobController extends BaseController< }); } + /** + * Generate a unique id for a background event. + * + * @returns An id. + */ + private generateBackgroundEventId(): string { + const id = uuid(); + if (this.state.events[id]) { + this.generateBackgroundEventId(); + } + return id; + } + /** * Runs every 24 hours to check if new jobs need to be scheduled. * @@ -335,42 +542,70 @@ export class CronjobController extends BaseController< }); } + /** + * Reschedule background events. + * + * @param backgroundEvents - A list of background events to reschdule. + */ + private async 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()) { + // removing expired events from state + this.update((state) => { + delete state.events[snapEvent.id]; + }); + + const error = new Error( + `Background event with id "${snapEvent.id}" not scheduled as its date has expired.`, + ); + logError(error); + } else { + this.setUpBackgroundEvent(snapEvent); + } + } + } + /** * Run controller teardown process and unsubscribe from Snap events. */ destroy() { super.destroy(); + const subscriptions = subscriptionMap.get(this); + /* eslint-disable @typescript-eslint/unbound-method */ this.messagingSystem.unsubscribe( 'SnapController:snapInstalled', - this._handleSnapRegisterEvent, + subscriptions.get('SnapController:snapInstalled'), ); this.messagingSystem.unsubscribe( 'SnapController:snapUninstalled', - this._handleSnapUnregisterEvent, + subscriptions.get('SnapController:snapUninstalled'), ); this.messagingSystem.unsubscribe( 'SnapController:snapEnabled', - this._handleSnapRegisterEvent, + subscriptions.get('SnapController:snapEnabled'), ); this.messagingSystem.unsubscribe( 'SnapController:snapDisabled', - this._handleSnapUnregisterEvent, + subscriptions.get('SnapController:snapDisabled'), ); this.messagingSystem.unsubscribe( 'SnapController:snapUpdated', - this._handleEventSnapUpdated, + subscriptions.get('SnapController:snapUpdated'), ); /* eslint-enable @typescript-eslint/unbound-method */ - this.#snapIds.forEach((snapId) => { - this.unregister(snapId); - }); + this.#snapIds.forEach((snapId) => this.unregister(snapId)); } /** @@ -383,7 +618,19 @@ export class CronjobController extends BaseController< } /** - * Handle events that should cause cronjobs to be unregistered. + * Handle events that could cause crobjobs 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).catch((error) => logError(error)); + this.register(snap.id); + } + + /** + * Handle events that should cause cronjobs and background events to be unregistered. * * @param snap - Basic Snap information. */ @@ -391,6 +638,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..2e56cf0184 100644 --- a/packages/snaps-controllers/src/test-utils/controller.ts +++ b/packages/snaps-controllers/src/test-utils/controller.ts @@ -675,6 +675,8 @@ export const getRestrictedCronjobControllerMessenger = ( 'PermissionController:getPermissions', 'SnapController:getAll', 'SnapController:handleRequest', + 'CronjobController:scheduleBackgroundEvent', + 'CronjobController:cancelBackgroundEvent', ], }); diff --git a/packages/snaps-rpc-methods/jest.config.js b/packages/snaps-rpc-methods/jest.config.js index 077ed1c65d..c5639ad031 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: 92.94, - functions: 97.26, - lines: 97.87, - statements: 97.39, + branches: 93.02, + functions: 97.35, + lines: 97.89, + statements: 97.44, }, }, }); diff --git a/packages/snaps-rpc-methods/package.json b/packages/snaps-rpc-methods/package.json index 7bc9be9723..e4b2408d7f 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/permitted/cancelBackgroundEvent.test.ts b/packages/snaps-rpc-methods/src/permitted/cancelBackgroundEvent.test.ts new file mode 100644 index 0000000000..cbf506a021 --- /dev/null +++ b/packages/snaps-rpc-methods/src/permitted/cancelBackgroundEvent.test.ts @@ -0,0 +1,138 @@ +import { JsonRpcEngine } from '@metamask/json-rpc-engine'; +import type { + CancelBackgroundEventParams, + CancelBackgroundEventResult, +} from '@metamask/snaps-sdk'; +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', () => { + it('returns null after calling the `scheduleBackgroundEvent` hook', async () => { + const { implementation } = cancelBackgroundEventHandler; + + const cancelBackgroundEvent = jest.fn(); + + const hooks = { + cancelBackgroundEvent, + }; + + const engine = new JsonRpcEngine(); + + 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 hooks = { + cancelBackgroundEvent, + }; + + const engine = new JsonRpcEngine(); + + 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 on invalid params', async () => { + const { implementation } = cancelBackgroundEventHandler; + + const cancelBackgroundEvent = jest.fn(); + + const hooks = { + cancelBackgroundEvent, + }; + + const engine = new JsonRpcEngine(); + + 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..0fdea7f090 --- /dev/null +++ b/packages/snaps-rpc-methods/src/permitted/cancelBackgroundEvent.ts @@ -0,0 +1,98 @@ +import type { JsonRpcEngineEndCallback } from '@metamask/json-rpc-engine'; +import type { PermittedHandlerExport } from '@metamask/permission-controller'; +import { 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 type { MethodHooksObject } from '../utils'; + +const methodName = 'snap_cancelBackgroundEvent'; + +const hookNames: MethodHooksObject = { + cancelBackgroundEvent: true, +}; + +export type CancelBackgroundEventMethodHooks = { + cancelBackgroundEvent: (id: string) => string; +}; + +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. + * @returns Nothing. + */ +async function getCancelBackgroundEventImplementation( + req: JsonRpcRequest, + res: PendingJsonRpcResponse, + _next: unknown, + end: JsonRpcEngineEndCallback, + { cancelBackgroundEvent }: CancelBackgroundEventMethodHooks, +): Promise { + const { params } = req; + + 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..84bf73afcf --- /dev/null +++ b/packages/snaps-rpc-methods/src/permitted/getBackgroundEvents.test.ts @@ -0,0 +1,105 @@ +import { JsonRpcEngine } from '@metamask/json-rpc-engine'; +import type { GetBackgroundEventsResult } from '@metamask/snaps-sdk'; +import { MOCK_SNAP_ID } from '@metamask/snaps-utils/test-utils'; +import type { 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', () => { + 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:00', + scheduledAt: '2021-01-01', + request: { + method: 'handleExport', + params: ['p1'], + }, + }, + ]; + + const getBackgroundEvents = jest + .fn() + .mockImplementation(() => backgroundEvents); + + const hooks = { + getBackgroundEvents, + }; + + const engine = new JsonRpcEngine(); + + engine.push((request, response, next, end) => { + const result = implementation( + request, + 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 hooks = { + getBackgroundEvents, + }; + + const engine = new JsonRpcEngine(); + + engine.push((request, response, next, end) => { + const result = implementation( + request, + response as PendingJsonRpcResponse, + next, + end, + hooks, + ); + + result?.catch(end); + }); + + await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: 'snap_getBackgroundEvents', + }); + + expect(getBackgroundEvents).toHaveBeenCalled(); + }); + }); +}); 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..1fc483c1cb --- /dev/null +++ b/packages/snaps-rpc-methods/src/permitted/getBackgroundEvents.ts @@ -0,0 +1,61 @@ +import type { JsonRpcEngineEndCallback } from '@metamask/json-rpc-engine'; +import type { PermittedHandlerExport } from '@metamask/permission-controller'; +import type { + BackgroundEvent, + GetBackgroundEventsResult, + JsonRpcParams, + JsonRpcRequest, +} from '@metamask/snaps-sdk'; +import { type PendingJsonRpcResponse } from '@metamask/utils'; + +import type { MethodHooksObject } from '../utils'; + +const methodName = 'snap_getBackgroundEvents'; + +const hookNames: MethodHooksObject = { + getBackgroundEvents: true, +}; + +export type GetBackgroundEventsMethodHooks = { + getBackgroundEvents: () => BackgroundEvent[]; +}; + +export const getBackgroundEventsHandler: PermittedHandlerExport< + GetBackgroundEventsMethodHooks, + JsonRpcParams, + 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. + * @returns An array of background events. + */ +async function getGetBackgroundEventsImplementation( + _req: JsonRpcRequest, + res: PendingJsonRpcResponse, + _next: unknown, + end: JsonRpcEngineEndCallback, + { getBackgroundEvents }: GetBackgroundEventsMethodHooks, +): Promise { + 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 83730b1a15..d55dbc2abd 100644 --- a/packages/snaps-rpc-methods/src/permitted/handlers.ts +++ b/packages/snaps-rpc-methods/src/permitted/handlers.ts @@ -1,6 +1,8 @@ +import { cancelBackgroundEventHandler } from './cancelBackgroundEvent'; 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'; @@ -11,6 +13,7 @@ import { invokeKeyringHandler } from './invokeKeyring'; import { invokeSnapSugarHandler } from './invokeSnapSugar'; import { requestSnapsHandler } from './requestSnaps'; import { resolveInterfaceHandler } from './resolveInterface'; +import { scheduleBackgroundEventHandler } from './scheduleBackgroundEvent'; import { updateInterfaceHandler } from './updateInterface'; /* eslint-disable @typescript-eslint/naming-convention */ @@ -29,6 +32,9 @@ export const methodHandlers = { snap_resolveInterface: resolveInterfaceHandler, snap_getCurrencyRate: getCurrencyRateHandler, snap_experimentalProviderRequest: providerRequestHandler, + snap_scheduleBackgroundEvent: scheduleBackgroundEventHandler, + snap_cancelBackgroundEvent: cancelBackgroundEventHandler, + snap_getBackgroundEvents: getBackgroundEventsHandler, }; /* 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 5aa676fce3..7615aa2a8b 100644 --- a/packages/snaps-rpc-methods/src/permitted/index.ts +++ b/packages/snaps-rpc-methods/src/permitted/index.ts @@ -1,12 +1,15 @@ +import type { CancelBackgroundEventMethodHooks } from './cancelBackgroundEvent'; 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'; import type { GetSnapsHooks } from './getSnaps'; import type { RequestSnapsHooks } from './requestSnaps'; import type { ResolveInterfaceMethodHooks } from './resolveInterface'; +import type { ScheduleBackgroundEventMethodHooks } from './scheduleBackgroundEvent'; import type { UpdateInterfaceMethodHooks } from './updateInterface'; export type PermittedRpcMethodHooks = GetAllSnapsHooks & @@ -18,7 +21,10 @@ export type PermittedRpcMethodHooks = GetAllSnapsHooks & GetInterfaceStateMethodHooks & ResolveInterfaceMethodHooks & GetCurrencyRateMethodHooks & - ProviderRequestMethodHooks; + ProviderRequestMethodHooks & + ScheduleBackgroundEventMethodHooks & + CancelBackgroundEventMethodHooks & + GetBackgroundEventsMethodHooks; export * from './handlers'; export * from './middleware'; diff --git a/packages/snaps-rpc-methods/src/permitted/scheduleBackground.test.ts b/packages/snaps-rpc-methods/src/permitted/scheduleBackground.test.ts new file mode 100644 index 0000000000..6cb46262b8 --- /dev/null +++ b/packages/snaps-rpc-methods/src/permitted/scheduleBackground.test.ts @@ -0,0 +1,157 @@ +import { JsonRpcEngine } from '@metamask/json-rpc-engine'; +import type { + ScheduleBackgroundEventParams, + ScheduleBackgroundEventResult, +} from '@metamask/snaps-sdk'; +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, + }, + }); + }); + }); + + describe('implementation', () => { + it('returns an id after calling the `scheduleBackgroundEvent` hook', async () => { + const { implementation } = scheduleBackgroundEventHandler; + + const scheduleBackgroundEvent = jest.fn().mockImplementation(() => 'foo'); + + const hooks = { + scheduleBackgroundEvent, + }; + + const engine = new JsonRpcEngine(); + + 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({ jsonrpc: '2.0', id: 1, result: 'foo' }); + }); + + it('schedules a background event', async () => { + const { implementation } = scheduleBackgroundEventHandler; + + const scheduleBackgroundEvent = jest.fn(); + + const hooks = { + scheduleBackgroundEvent, + }; + + const engine = new JsonRpcEngine(); + + 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', + request: { + method: 'handleExport', + params: ['p1'], + }, + }, + }); + + expect(scheduleBackgroundEvent).toHaveBeenCalledWith({ + scheduledAt: expect.any(String), + date: '2022-01-01T01:00', + request: { + method: 'handleExport', + params: ['p1'], + }, + }); + }); + + it('throws on invalid params', async () => { + const { implementation } = scheduleBackgroundEventHandler; + + const scheduleBackgroundEvent = jest.fn(); + + const hooks = { + scheduleBackgroundEvent, + }; + + const engine = new JsonRpcEngine(); + + 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 ISO8601 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..329c21d46b --- /dev/null +++ b/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.ts @@ -0,0 +1,128 @@ +import type { JsonRpcEngineEndCallback } from '@metamask/json-rpc-engine'; +import type { PermittedHandlerExport } from '@metamask/permission-controller'; +import { 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 { type PendingJsonRpcResponse } from '@metamask/utils'; +import { DateTime } from 'luxon'; + +import type { MethodHooksObject } from '../utils'; + +const methodName = 'snap_scheduleBackgroundEvent'; + +const hookNames: MethodHooksObject = { + scheduleBackgroundEvent: true, +}; + +type ScheduleBackgroundEventHookParams = { + date: string; + scheduledAt: string; + request: CronjobRpcRequest; +}; + +export type ScheduleBackgroundEventMethodHooks = { + scheduleBackgroundEvent: ( + snapEvent: ScheduleBackgroundEventHookParams, + ) => string; +}; + +export const scheduleBackgroundEventHandler: PermittedHandlerExport< + ScheduleBackgroundEventMethodHooks, + ScheduleBackgroundEventParameters, + ScheduleBackgroundEventResult +> = { + methodNames: [methodName], + implementation: getScheduleBackgroundEventImplementation, + hookNames, +}; + +const ScheduleBackgroundEventsParametersStruct = object({ + date: refine(string(), 'date', (val) => { + const date = DateTime.fromISO(val); + if (date.isValid) { + return true; + } + return 'Not a valid ISO8601 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. + * @returns An id representing the background event. + */ +async function getScheduleBackgroundEventImplementation( + req: JsonRpcRequest, + res: PendingJsonRpcResponse, + _next: unknown, + end: JsonRpcEngineEndCallback, + { scheduleBackgroundEvent }: ScheduleBackgroundEventMethodHooks, +): Promise { + const { params } = req; + + try { + const validatedParams = getValidatedParams(params); + + const { date, request } = validatedParams; + + const scheduledAt = new Date().toISOString(); + + const id = scheduleBackgroundEvent({ date, request, scheduledAt }); + res.result = id; + } catch (error) { + return end(error); + } + + return end(); +} + +/** + * Validate the scheduleBackground 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-rpc-methods/src/restricted/cancelBackground.test.ts b/packages/snaps-rpc-methods/src/restricted/cancelBackground.test.ts deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/packages/snaps-rpc-methods/src/restricted/cancelBackgroundEvent.ts b/packages/snaps-rpc-methods/src/restricted/cancelBackgroundEvent.ts deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/packages/snaps-rpc-methods/src/restricted/scheduleBackground.test.ts b/packages/snaps-rpc-methods/src/restricted/scheduleBackground.test.ts deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/packages/snaps-rpc-methods/src/restricted/scheduleBackgroundEvent.ts b/packages/snaps-rpc-methods/src/restricted/scheduleBackgroundEvent.ts deleted file mode 100644 index b51a09f4ac..0000000000 --- a/packages/snaps-rpc-methods/src/restricted/scheduleBackgroundEvent.ts +++ /dev/null @@ -1,32 +0,0 @@ -const methodName = 'snap_scheduleBackgroundEvent'; - -/** - * The specification builder for the `snap_dialog` permission. `snap_dialog` - * lets the Snap display one of the following dialogs to the user: - * - An alert, for displaying information. - * - A confirmation, for accepting or rejecting some action. - * - A prompt, for inputting some information. - * - * @param options - The specification builder options. - * @param options.allowedCaveats - The optional allowed caveats for the - * permission. - * @param options.methodHooks - The RPC method hooks needed by the method - * implementation. - * @returns The specification for the `snap_dialog` permission. - */ -const specificationBuilder: PermissionSpecificationBuilder< - PermissionType.RestrictedMethod, - DialogSpecificationBuilderOptions, - DialogSpecification -> = ({ - allowedCaveats = null, - methodHooks, -}: DialogSpecificationBuilderOptions) => { - return { - permissionType: PermissionType.RestrictedMethod, - targetName: methodName, - allowedCaveats, - methodImplementation: getDialogImplementation(methodHooks), - subjectTypes: [SubjectType.Snap], - }; -}; \ No newline at end of file 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..1121312784 --- /dev/null +++ b/packages/snaps-sdk/src/types/methods/cancel-background-event.ts @@ -0,0 +1,5 @@ +export type CancelBackgroundEventParams = { + id: string; +}; + +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..3ce1e154d4 --- /dev/null +++ b/packages/snaps-sdk/src/types/methods/get-background-events.ts @@ -0,0 +1,18 @@ +import type { Json } from '@metamask/utils'; + +import type { SnapId } from '../snap'; + +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; + }; +}; + +export type GetBackgroundEventsResult = BackgroundEvent[]; diff --git a/packages/snaps-sdk/src/types/methods/index.ts b/packages/snaps-sdk/src/types/methods/index.ts index 02d604df85..17760ede03 100644 --- a/packages/snaps-sdk/src/types/methods/index.ts +++ b/packages/snaps-sdk/src/types/methods/index.ts @@ -22,3 +22,6 @@ export * from './update-interface'; export * from './resolve-interface'; export * from './get-currency-rate'; export * from './provider-request'; +export * from './schedule-background-event'; +export * from './cancel-background-event'; +export * from './get-background-events'; 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..70e284fa28 --- /dev/null +++ b/packages/snaps-sdk/src/types/methods/schedule-background-event.ts @@ -0,0 +1,8 @@ +import type { Cronjob } from '../permissions'; + +export type ScheduleBackgroundEventParams = { + date: string; + request: Cronjob['request']; +}; + +export type ScheduleBackgroundEventResult = string; diff --git a/yarn.lock b/yarn.lock index 7041b9ad4e..ebc52fd346 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5783,6 +5783,7 @@ __metadata: tar-stream: "npm:^3.1.7" ts-node: "npm:^10.9.1" typescript: "npm:~5.3.3" + uuid: "npm:^11.0.3" vite: "npm:^4.3.9" vite-tsconfig-paths: "npm:^4.0.5" wdio-chromedriver-service: "npm:^8.1.1" @@ -6002,6 +6003,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" @@ -6018,6 +6020,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" @@ -7922,6 +7925,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" @@ -17237,6 +17247,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." @@ -20474,6 +20491,7 @@ __metadata: "@ts-bridge/cli": "npm:^0.6.1" "@types/jest": "npm:^27.5.1" "@types/lodash": "npm:^4" + "@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" @@ -20495,6 +20513,7 @@ __metadata: jest-silent-reporter: "npm:^0.6.0" lint-staged: "npm:^12.4.1" lodash: "npm:^4.17.21" + luxon: "npm:^3.5.0" minimatch: "npm:^7.4.1" prettier: "npm:^2.8.8" prettier-plugin-packagejson: "npm:^2.5.2" @@ -20504,6 +20523,7 @@ __metadata: ts-node: "npm:^10.9.1" tsx: "npm:^4.19.1" typescript: "npm:~5.3.3" + uuid: "npm:^11.0.3" vite: "npm:^4.3.9" languageName: unknown linkType: soft @@ -22653,6 +22673,15 @@ __metadata: languageName: node linkType: hard +"uuid@npm:^11.0.3": + version: 11.0.3 + resolution: "uuid@npm:11.0.3" + bin: + uuid: dist/esm/bin/uuid + checksum: 10/251385563195709eb0697c74a834764eef28e1656d61174e35edbd129288acb4d95a43f4ce8a77b8c2fc128e2b55924296a0945f964b05b9173469d045625ff2 + languageName: node + linkType: hard + "uuid@npm:^8.3.2": version: 8.3.2 resolution: "uuid@npm:8.3.2" From d52a56c7389c1eeab8f6d54b86893e99e086ce03 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Tue, 10 Dec 2024 18:52:38 -0500 Subject: [PATCH 03/13] update coverage and rebuild --- .../packages/browserify-plugin/snap.manifest.json | 2 +- packages/examples/packages/browserify/snap.manifest.json | 2 +- packages/snaps-controllers/coverage.json | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/examples/packages/browserify-plugin/snap.manifest.json b/packages/examples/packages/browserify-plugin/snap.manifest.json index f7331080fd..160ebe7674 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": "SquG9JvLanG/gJwBw5H1AZBlsthmv21Ci4Vn+sMemjM=", + "shasum": "XMEM/j4XBZLY1nz8Ow7kFic+ReGDTkBwZrGmAnj5YJk=", "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 db40ba24e3..cebc08034d 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": "pCp96i558WHqHIUZyZGUFcxAfOQ0afBHJ59nJB5ma78=", + "shasum": "4Ld6BCuGlD4EQddRn2VHfW+NaDKU4SR54G5IW5bN3D8=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/snaps-controllers/coverage.json b/packages/snaps-controllers/coverage.json index 28873f6536..31e59ff24e 100644 --- a/packages/snaps-controllers/coverage.json +++ b/packages/snaps-controllers/coverage.json @@ -1,6 +1,6 @@ { - "branches": 92.89, - "functions": 96.71, - "lines": 98, - "statements": 97.71 + "branches": 92.8, + "functions": 95.25, + "lines": 97.76, + "statements": 97.43 } From 0fe6ee986c0230ed6b784033bc1ce2cd7e28f0d5 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Tue, 10 Dec 2024 18:55:13 -0500 Subject: [PATCH 04/13] fix type --- .../snaps-rpc-methods/src/permitted/cancelBackgroundEvent.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/snaps-rpc-methods/src/permitted/cancelBackgroundEvent.ts b/packages/snaps-rpc-methods/src/permitted/cancelBackgroundEvent.ts index 0fdea7f090..9c0bbb3908 100644 --- a/packages/snaps-rpc-methods/src/permitted/cancelBackgroundEvent.ts +++ b/packages/snaps-rpc-methods/src/permitted/cancelBackgroundEvent.ts @@ -19,7 +19,7 @@ const hookNames: MethodHooksObject = { }; export type CancelBackgroundEventMethodHooks = { - cancelBackgroundEvent: (id: string) => string; + cancelBackgroundEvent: (id: string) => void; }; export const cancelBackgroundEventHandler: PermittedHandlerExport< From deea633b9efbadfb67f0d85c99b219b0cb8e081d Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Tue, 10 Dec 2024 18:56:30 -0500 Subject: [PATCH 05/13] fix spacing --- .../snaps-rpc-methods/src/permitted/cancelBackgroundEvent.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/snaps-rpc-methods/src/permitted/cancelBackgroundEvent.ts b/packages/snaps-rpc-methods/src/permitted/cancelBackgroundEvent.ts index 9c0bbb3908..4976a6b468 100644 --- a/packages/snaps-rpc-methods/src/permitted/cancelBackgroundEvent.ts +++ b/packages/snaps-rpc-methods/src/permitted/cancelBackgroundEvent.ts @@ -77,8 +77,7 @@ async function getCancelBackgroundEventImplementation( } /** - * Validate the cancelBackgroundEvent method `params` and returns them cast to the correct - * type. Throws if validation fails. + * 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. From 4e4123426240ccd6d64d4f4f6d8b2b6419697695 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Tue, 10 Dec 2024 18:59:06 -0500 Subject: [PATCH 06/13] fix jsdoc --- .../snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.ts b/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.ts index 329c21d46b..a88f93b3c0 100644 --- a/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.ts +++ b/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.ts @@ -105,7 +105,7 @@ async function getScheduleBackgroundEventImplementation( } /** - * Validate the scheduleBackground method `params` and returns them cast to the correct + * 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. From 621d9f96f2ca58ba657df669722db91fe3d0ce53 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Tue, 10 Dec 2024 19:03:25 -0500 Subject: [PATCH 07/13] remove uuid in favor of nanoid --- package.json | 1 - packages/snaps-controllers/package.json | 3 +-- .../src/cronjob/CronjobController.ts | 4 ++-- yarn.lock | 11 ----------- 4 files changed, 3 insertions(+), 16 deletions(-) diff --git a/package.json b/package.json index 8f468f0206..5c7d39160a 100644 --- a/package.json +++ b/package.json @@ -116,7 +116,6 @@ "ts-node": "^10.9.1", "tsx": "^4.19.1", "typescript": "~5.3.3", - "uuid": "^11.0.3", "vite": "^4.3.9" }, "packageManager": "yarn@4.4.1", diff --git a/packages/snaps-controllers/package.json b/packages/snaps-controllers/package.json index d9c65588d7..b865a31ac0 100644 --- a/packages/snaps-controllers/package.json +++ b/packages/snaps-controllers/package.json @@ -104,8 +104,7 @@ "readable-stream": "^3.6.2", "readable-web-to-node-stream": "^3.0.2", "semver": "^7.5.4", - "tar-stream": "^3.1.7", - "uuid": "^11.0.3" + "tar-stream": "^3.1.7" }, "devDependencies": { "@esbuild-plugins/node-globals-polyfill": "^0.2.3", diff --git a/packages/snaps-controllers/src/cronjob/CronjobController.ts b/packages/snaps-controllers/src/cronjob/CronjobController.ts index ce2333db7e..d5be331285 100644 --- a/packages/snaps-controllers/src/cronjob/CronjobController.ts +++ b/packages/snaps-controllers/src/cronjob/CronjobController.ts @@ -21,7 +21,7 @@ import { } from '@metamask/snaps-utils'; import { assert, Duration, hasProperty, inMilliseconds } from '@metamask/utils'; import { castDraft } from 'immer'; -import { v4 as uuid } from 'uuid'; +import { nanoid } from 'nanoid'; import type { GetAllSnaps, @@ -502,7 +502,7 @@ export class CronjobController extends BaseController< * @returns An id. */ private generateBackgroundEventId(): string { - const id = uuid(); + const id = nanoid(); if (this.state.events[id]) { this.generateBackgroundEventId(); } diff --git a/yarn.lock b/yarn.lock index ebc52fd346..23ea3b58d6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5783,7 +5783,6 @@ __metadata: tar-stream: "npm:^3.1.7" ts-node: "npm:^10.9.1" typescript: "npm:~5.3.3" - uuid: "npm:^11.0.3" vite: "npm:^4.3.9" vite-tsconfig-paths: "npm:^4.0.5" wdio-chromedriver-service: "npm:^8.1.1" @@ -20523,7 +20522,6 @@ __metadata: ts-node: "npm:^10.9.1" tsx: "npm:^4.19.1" typescript: "npm:~5.3.3" - uuid: "npm:^11.0.3" vite: "npm:^4.3.9" languageName: unknown linkType: soft @@ -22673,15 +22671,6 @@ __metadata: languageName: node linkType: hard -"uuid@npm:^11.0.3": - version: 11.0.3 - resolution: "uuid@npm:11.0.3" - bin: - uuid: dist/esm/bin/uuid - checksum: 10/251385563195709eb0697c74a834764eef28e1656d61174e35edbd129288acb4d95a43f4ce8a77b8c2fc128e2b55924296a0945f964b05b9173469d045625ff2 - languageName: node - linkType: hard - "uuid@npm:^8.3.2": version: 8.3.2 resolution: "uuid@npm:8.3.2" From bceaa05f0d9c09a69ef98e78aff924203ae1cb1e Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Tue, 10 Dec 2024 19:11:50 -0500 Subject: [PATCH 08/13] fix jsdocs --- packages/snaps-controllers/src/cronjob/CronjobController.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/snaps-controllers/src/cronjob/CronjobController.ts b/packages/snaps-controllers/src/cronjob/CronjobController.ts index d5be331285..5c68700607 100644 --- a/packages/snaps-controllers/src/cronjob/CronjobController.ts +++ b/packages/snaps-controllers/src/cronjob/CronjobController.ts @@ -457,8 +457,7 @@ export class CronjobController extends BaseController< * 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. + * @param skipEvents - Whether the unregistration process should skip scheduled background events. */ unregister(snapId: string, skipEvents = false) { const jobs = [...this.#snapIds.entries()].filter( @@ -618,7 +617,7 @@ export class CronjobController extends BaseController< } /** - * Handle events that could cause crobjobs to be registered + * Handle events that could cause cronjobs to be registered * and for background events to be rescheduled. * * @param snap - Basic Snap information. From c22ce079bdf9c25f9a423a7d16714967c3c4ad85 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Wed, 11 Dec 2024 11:28:01 -0500 Subject: [PATCH 09/13] address PR comments --- package.json | 2 - .../src/cronjob/CronjobController.test.ts | 201 +++++++++++++++++- .../src/cronjob/CronjobController.ts | 139 +++++------- packages/snaps-rpc-methods/jest.config.js | 6 +- .../src/permitted/getBackgroundEvents.test.ts | 57 ++++- .../src/permitted/getBackgroundEvents.ts | 6 +- ...est.ts => scheduleBackgroundEvent.test.ts} | 70 +++++- .../src/permitted/scheduleBackgroundEvent.ts | 25 ++- .../types/methods/get-background-events.ts | 11 + .../methods/schedule-background-event.ts | 6 + yarn.lock | 2 - 11 files changed, 417 insertions(+), 108 deletions(-) rename packages/snaps-rpc-methods/src/permitted/{scheduleBackground.test.ts => scheduleBackgroundEvent.test.ts} (65%) diff --git a/package.json b/package.json index 5c7d39160a..daad18532d 100644 --- a/package.json +++ b/package.json @@ -84,7 +84,6 @@ "@ts-bridge/cli": "^0.6.1", "@types/jest": "^27.5.1", "@types/lodash": "^4", - "@types/luxon": "^3", "@types/node": "18.14.2", "@typescript-eslint/eslint-plugin": "^5.42.1", "@typescript-eslint/parser": "^6.21.0", @@ -106,7 +105,6 @@ "jest-silent-reporter": "^0.6.0", "lint-staged": "^12.4.1", "lodash": "^4.17.21", - "luxon": "^3.5.0", "minimatch": "^7.4.1", "prettier": "^2.8.8", "prettier-plugin-packagejson": "^2.5.2", diff --git a/packages/snaps-controllers/src/cronjob/CronjobController.test.ts b/packages/snaps-controllers/src/cronjob/CronjobController.test.ts index eb16615bff..5c64c5863f 100644 --- a/packages/snaps-controllers/src/cronjob/CronjobController.test.ts +++ b/packages/snaps-controllers/src/cronjob/CronjobController.test.ts @@ -312,7 +312,7 @@ describe('CronjobController', () => { [id]: { id, scheduledAt: expect.any(String), ...backgroundEvent }, }); - cronjobController.cancelBackgroundEvent(id); + cronjobController.cancelBackgroundEvent(id, MOCK_SNAP_ID); jest.advanceTimersByTime(inMilliseconds(1, Duration.Day)); @@ -334,6 +334,37 @@ describe('CronjobController', () => { 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:00', + 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(id, 'foo')).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 = @@ -808,4 +839,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:00', + request: { + method: 'handleExport', + params: ['p1'], + }, + }, + ); + + expect(cronjobController.state.events).toStrictEqual({ + [id]: { + id, + snapId: MOCK_SNAP_ID, + scheduledAt: expect.any(String), + date: '2022-01-01T01:00', + 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:00', + request: { + method: 'handleExport', + params: ['p1'], + }, + }, + ); + + expect(cronjobController.state.events).toStrictEqual({ + [id]: { + id, + snapId: MOCK_SNAP_ID, + scheduledAt: expect.any(String), + date: '2022-01-01T01:00', + request: { + method: 'handleExport', + params: ['p1'], + }, + }, + }); + + rootMessenger.call( + 'CronjobController:cancelBackgroundEvent', + id, + MOCK_SNAP_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:00', + request: { + method: 'handleExport', + params: ['p1'], + }, + }, + ); + + expect(cronjobController.state.events).toStrictEqual({ + [id]: { + id, + snapId: MOCK_SNAP_ID, + scheduledAt: expect.any(String), + date: '2022-01-01T01:00', + 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:00', + 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 5c68700607..81e8091e8b 100644 --- a/packages/snaps-controllers/src/cronjob/CronjobController.ts +++ b/packages/snaps-controllers/src/cronjob/CronjobController.ts @@ -19,7 +19,7 @@ import { parseCronExpression, logError, } from '@metamask/snaps-utils'; -import { assert, Duration, hasProperty, inMilliseconds } from '@metamask/utils'; +import { assert, Duration, inMilliseconds } from '@metamask/utils'; import { castDraft } from 'immer'; import { nanoid } from 'nanoid'; @@ -149,11 +149,6 @@ export class CronjobController extends BaseController< this.#snapIds = new Map(); this.#messenger = messenger; - this._handleSnapRegisterEvent = this._handleSnapRegisterEvent.bind(this); - this._handleSnapUnregisterEvent = - this._handleSnapUnregisterEvent.bind(this); - this._handleEventSnapUpdated = this._handleEventSnapUpdated.bind(this); - // Subscribe to Snap events /* eslint-disable @typescript-eslint/unbound-method */ @@ -227,7 +222,7 @@ export class CronjobController extends BaseController< logError(error); }); - this.rescheduleBackgroundEvents(Object.values(this.state.events)).catch( + this.#rescheduleBackgroundEvents(Object.values(this.state.events)).catch( (error) => { logError(error); }, @@ -239,11 +234,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[]; } @@ -254,7 +249,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, @@ -275,8 +270,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)); } /** @@ -290,7 +285,7 @@ export class CronjobController extends BaseController< * * @param job - Cronjob specification. */ - private schedule(job: Cronjob) { + #schedule(job: Cronjob) { if (this.#timers.has(job.id)) { return; } @@ -307,17 +302,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); @@ -329,8 +324,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: '', @@ -348,9 +343,12 @@ export class CronjobController extends BaseController< scheduleBackgroundEvent( backgroundEventWithoutId: Omit, ) { - const event = this.getBackgroundEventWithId(backgroundEventWithoutId); - event.scheduledAt = new Date().toISOString(); - this.setUpBackgroundEvent(event); + const event = { + ...backgroundEventWithoutId, + id: nanoid(), + scheduledAt: new Date().toISOString(), + }; + this.#setUpBackgroundEvent(event); this.update((state) => { state.events[event.id] = castDraft(event); }); @@ -362,14 +360,20 @@ export class CronjobController extends BaseController< * Cancel a background event. * * @param id - The id of the background event to cancel. + * @param origin - The origin making the cancel call. * @throws If the event does not exist. */ - cancelBackgroundEvent(id: string) { + cancelBackgroundEvent(id: string, origin: 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); @@ -379,42 +383,28 @@ export class CronjobController extends BaseController< }); } - /** - * Assign an id to a background event. - * - * @param backgroundEventWithoutId - A background event with an unassigned id. - * @returns A background event with an id. - */ - private getBackgroundEventWithId( - backgroundEventWithoutId: Omit, - ): BackgroundEvent { - assert( - !hasProperty(backgroundEventWithoutId, 'id'), - `Background event already has an id: ${ - (backgroundEventWithoutId as BackgroundEvent).id - }`, - ); - const event = backgroundEventWithoutId as BackgroundEvent; - const id = this.generateBackgroundEventId(); - event.id = id; - return event; - } - /** * A helper function to handle setup of the background event. * * @param event - A background event. */ - private setUpBackgroundEvent(event: BackgroundEvent) { + #setUpBackgroundEvent(event: BackgroundEvent) { const date = new Date(event.date); const now = new Date(); const ms = date.getTime() - now.getTime(); const timer = new Timer(ms); timer.start(() => { - this.executeBackgroundEvent(event).catch((error) => { - logError(error); - }); + 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); @@ -427,20 +417,6 @@ export class CronjobController extends BaseController< this.#snapIds.set(event.id, event.snapId); } - /** - * Fire the background event. - * - * @param event - A background event. - */ - private async executeBackgroundEvent(event: BackgroundEvent) { - await this.#messenger.call('SnapController:handleRequest', { - snapId: event.snapId, - origin: '', - handler: HandlerType.OnCronjob, - request: event.request, - }); - } - /** * Get a list of a Snap's background events. * @@ -465,6 +441,7 @@ export class CronjobController extends BaseController< ); if (jobs.length) { + const eventIds: string[] = []; jobs.forEach(([id]) => { const timer = this.#timers.get(id); if (timer) { @@ -472,12 +449,17 @@ export class CronjobController extends BaseController< this.#timers.delete(id); this.#snapIds.delete(id); if (!skipEvents && this.state.events[id]) { - this.update((state) => { - delete state.events[id]; - }); + eventIds.push(id); } } }); + if (eventIds.length > 0) { + this.update((state) => { + eventIds.forEach((id) => { + delete state.events[id]; + }); + }); + } } } @@ -487,7 +469,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, @@ -495,26 +477,13 @@ export class CronjobController extends BaseController< }); } - /** - * Generate a unique id for a background event. - * - * @returns An id. - */ - private generateBackgroundEventId(): string { - const id = nanoid(); - if (this.state.events[id]) { - this.generateBackgroundEventId(); - } - return id; - } - /** * Runs every 24 hours to check if new jobs need to be scheduled. * * 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); @@ -525,11 +494,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); @@ -546,9 +515,7 @@ export class CronjobController extends BaseController< * * @param backgroundEvents - A list of background events to reschdule. */ - private async rescheduleBackgroundEvents( - backgroundEvents: BackgroundEvent[], - ) { + async #rescheduleBackgroundEvents(backgroundEvents: BackgroundEvent[]) { for (const snapEvent of backgroundEvents) { const { date } = snapEvent; const now = new Date(); @@ -564,7 +531,7 @@ export class CronjobController extends BaseController< ); logError(error); } else { - this.setUpBackgroundEvent(snapEvent); + this.#setUpBackgroundEvent(snapEvent); } } } @@ -624,7 +591,7 @@ export class CronjobController extends BaseController< */ private _handleSnapEnabledEvent(snap: TruncatedSnap) { const events = this.getBackgroundEvents(snap.id); - this.rescheduleBackgroundEvents(events).catch((error) => logError(error)); + this.#rescheduleBackgroundEvents(events).catch((error) => logError(error)); this.register(snap.id); } diff --git a/packages/snaps-rpc-methods/jest.config.js b/packages/snaps-rpc-methods/jest.config.js index c5639ad031..73bee7457a 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.02, + branches: 93.05, functions: 97.35, - lines: 97.89, - statements: 97.44, + lines: 97.99, + statements: 97.53, }, }, }); diff --git a/packages/snaps-rpc-methods/src/permitted/getBackgroundEvents.test.ts b/packages/snaps-rpc-methods/src/permitted/getBackgroundEvents.test.ts index 84bf73afcf..682ec51914 100644 --- a/packages/snaps-rpc-methods/src/permitted/getBackgroundEvents.test.ts +++ b/packages/snaps-rpc-methods/src/permitted/getBackgroundEvents.test.ts @@ -1,7 +1,10 @@ import { JsonRpcEngine } from '@metamask/json-rpc-engine'; -import type { GetBackgroundEventsResult } from '@metamask/snaps-sdk'; +import type { + GetBackgroundEventsParams, + GetBackgroundEventsResult, +} from '@metamask/snaps-sdk'; import { MOCK_SNAP_ID } from '@metamask/snaps-utils/test-utils'; -import type { PendingJsonRpcResponse } from '@metamask/utils'; +import type { JsonRpcRequest, PendingJsonRpcResponse } from '@metamask/utils'; import { getBackgroundEventsHandler } from './getBackgroundEvents'; @@ -47,7 +50,7 @@ describe('snap_getBackgroundEvents', () => { engine.push((request, response, next, end) => { const result = implementation( - request, + request as JsonRpcRequest, response as PendingJsonRpcResponse, next, end, @@ -83,7 +86,7 @@ describe('snap_getBackgroundEvents', () => { engine.push((request, response, next, end) => { const result = implementation( - request, + request as JsonRpcRequest, response as PendingJsonRpcResponse, next, end, @@ -101,5 +104,51 @@ describe('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 hooks = { + getBackgroundEvents, + }; + + const engine = new JsonRpcEngine(); + + 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', + }); + }); }); }); diff --git a/packages/snaps-rpc-methods/src/permitted/getBackgroundEvents.ts b/packages/snaps-rpc-methods/src/permitted/getBackgroundEvents.ts index 1fc483c1cb..f94e78e470 100644 --- a/packages/snaps-rpc-methods/src/permitted/getBackgroundEvents.ts +++ b/packages/snaps-rpc-methods/src/permitted/getBackgroundEvents.ts @@ -2,8 +2,8 @@ import type { JsonRpcEngineEndCallback } from '@metamask/json-rpc-engine'; import type { PermittedHandlerExport } from '@metamask/permission-controller'; import type { BackgroundEvent, + GetBackgroundEventsParams, GetBackgroundEventsResult, - JsonRpcParams, JsonRpcRequest, } from '@metamask/snaps-sdk'; import { type PendingJsonRpcResponse } from '@metamask/utils'; @@ -22,7 +22,7 @@ export type GetBackgroundEventsMethodHooks = { export const getBackgroundEventsHandler: PermittedHandlerExport< GetBackgroundEventsMethodHooks, - JsonRpcParams, + GetBackgroundEventsParams, GetBackgroundEventsResult > = { methodNames: [methodName], @@ -44,7 +44,7 @@ export const getBackgroundEventsHandler: PermittedHandlerExport< * @returns An array of background events. */ async function getGetBackgroundEventsImplementation( - _req: JsonRpcRequest, + _req: JsonRpcRequest, res: PendingJsonRpcResponse, _next: unknown, end: JsonRpcEngineEndCallback, diff --git a/packages/snaps-rpc-methods/src/permitted/scheduleBackground.test.ts b/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.test.ts similarity index 65% rename from packages/snaps-rpc-methods/src/permitted/scheduleBackground.test.ts rename to packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.test.ts index 6cb46262b8..e563510489 100644 --- a/packages/snaps-rpc-methods/src/permitted/scheduleBackground.test.ts +++ b/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.test.ts @@ -3,8 +3,10 @@ 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 { SnapEndowments } from '../endowments'; import { scheduleBackgroundEventHandler } from './scheduleBackgroundEvent'; describe('snap_scheduleBackgroundEvent', () => { @@ -15,23 +17,34 @@ describe('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, @@ -64,13 +77,16 @@ describe('snap_scheduleBackgroundEvent', () => { 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, @@ -97,7 +113,6 @@ describe('snap_scheduleBackgroundEvent', () => { }); expect(scheduleBackgroundEvent).toHaveBeenCalledWith({ - scheduledAt: expect.any(String), date: '2022-01-01T01:00', request: { method: 'handleExport', @@ -106,17 +121,70 @@ describe('snap_scheduleBackgroundEvent', () => { }); }); + 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: 'foobar', + request: { + method: 'handleExport', + params: ['p1'], + }, + }, + }); + + expect(response).toStrictEqual({ + error: { + code: -32600, + message: `The snap "${MOCK_SNAP_ID}" does not have the "${SnapEndowments.Cronjob}" permission.`, + 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, diff --git a/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.ts b/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.ts index a88f93b3c0..a19d440fed 100644 --- a/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.ts +++ b/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.ts @@ -21,17 +21,18 @@ import { import { 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; - scheduledAt: string; request: CronjobRpcRequest; }; @@ -39,6 +40,8 @@ export type ScheduleBackgroundEventMethodHooks = { scheduleBackgroundEvent: ( snapEvent: ScheduleBackgroundEventHookParams, ) => string; + + hasPermission: (permissionName: string) => boolean; }; export const scheduleBackgroundEventHandler: PermittedHandlerExport< @@ -77,6 +80,7 @@ export type ScheduleBackgroundEventParameters = InferMatching< * @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( @@ -84,18 +88,27 @@ async function getScheduleBackgroundEventImplementation( res: PendingJsonRpcResponse, _next: unknown, end: JsonRpcEngineEndCallback, - { scheduleBackgroundEvent }: ScheduleBackgroundEventMethodHooks, + { + scheduleBackgroundEvent, + hasPermission, + }: ScheduleBackgroundEventMethodHooks, ): Promise { - const { params } = req; + const { params, origin } = req as JsonRpcRequest & { origin: string }; + + if (!hasPermission(SnapEndowments.Cronjob)) { + return end( + rpcErrors.invalidRequest({ + message: `The snap "${origin}" does not have the "${SnapEndowments.Cronjob}" permission.`, + }), + ); + } try { const validatedParams = getValidatedParams(params); const { date, request } = validatedParams; - const scheduledAt = new Date().toISOString(); - - const id = scheduleBackgroundEvent({ date, request, scheduledAt }); + const id = scheduleBackgroundEvent({ date, request }); res.result = id; } catch (error) { return end(error); diff --git a/packages/snaps-sdk/src/types/methods/get-background-events.ts b/packages/snaps-sdk/src/types/methods/get-background-events.ts index 3ce1e154d4..b35240bab6 100644 --- a/packages/snaps-sdk/src/types/methods/get-background-events.ts +++ b/packages/snaps-sdk/src/types/methods/get-background-events.ts @@ -2,6 +2,9 @@ import type { Json } from '@metamask/utils'; import type { SnapId } from '../snap'; +/** + * Backgound event type + */ export type BackgroundEvent = { id: string; scheduledAt: string; @@ -15,4 +18,12 @@ export type BackgroundEvent = { }; }; +/** + * `snap_getBackgroundEvents` result type. + */ export type GetBackgroundEventsResult = BackgroundEvent[]; + +/** + * `snao_getBackgroundEvents` params. + */ +export type GetBackgroundEventsParams = never; diff --git a/packages/snaps-sdk/src/types/methods/schedule-background-event.ts b/packages/snaps-sdk/src/types/methods/schedule-background-event.ts index 70e284fa28..1bb07bb969 100644 --- a/packages/snaps-sdk/src/types/methods/schedule-background-event.ts +++ b/packages/snaps-sdk/src/types/methods/schedule-background-event.ts @@ -1,8 +1,14 @@ import type { Cronjob } from '../permissions'; +/** + * Params for the `snap_scheduleBackgroundEvent` method. + */ export type ScheduleBackgroundEventParams = { date: string; request: Cronjob['request']; }; +/** + * `snap_scheduleBackgroundEvent` return type. + */ export type ScheduleBackgroundEventResult = string; diff --git a/yarn.lock b/yarn.lock index 23ea3b58d6..14be0e30a8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -20490,7 +20490,6 @@ __metadata: "@ts-bridge/cli": "npm:^0.6.1" "@types/jest": "npm:^27.5.1" "@types/lodash": "npm:^4" - "@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" @@ -20512,7 +20511,6 @@ __metadata: jest-silent-reporter: "npm:^0.6.0" lint-staged: "npm:^12.4.1" lodash: "npm:^4.17.21" - luxon: "npm:^3.5.0" minimatch: "npm:^7.4.1" prettier: "npm:^2.8.8" prettier-plugin-packagejson: "npm:^2.5.2" From f4efd98616f578023ac794200e45bb095a7d55e7 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Wed, 11 Dec 2024 11:53:42 -0500 Subject: [PATCH 10/13] update example snap --- .../packages/cronjobs/snap.manifest.json | 4 ++ .../examples/packages/cronjobs/src/index.ts | 50 ++++++++++++++++++- .../snaps-sdk/src/types/methods/methods.ts | 24 +++++++++ 3 files changed, 77 insertions(+), 1 deletion(-) diff --git a/packages/examples/packages/cronjobs/snap.manifest.json b/packages/examples/packages/cronjobs/snap.manifest.json index 7577b5cf3b..e40e3b2509 100644 --- a/packages/examples/packages/cronjobs/snap.manifest.json +++ b/packages/examples/packages/cronjobs/snap.manifest.json @@ -17,6 +17,10 @@ } }, "initialPermissions": { + "endowment:rpc": { + "dapps": true, + "snaps": false + }, "endowment:cronjob": { "jobs": [ { diff --git a/packages/examples/packages/cronjobs/src/index.ts b/packages/examples/packages/cronjobs/src/index.ts index 56930dd1e7..2a7f5621db 100644 --- a/packages/examples/packages/cronjobs/src/index.ts +++ b/packages/examples/packages/cronjobs/src/index.ts @@ -1,4 +1,7 @@ -import type { OnCronjobHandler } from '@metamask/snaps-sdk'; +import type { + OnCronjobHandler, + OnRpcRequestHandler, +} from '@metamask/snaps-sdk'; import { panel, text, heading, MethodNotFoundError } from '@metamask/snaps-sdk'; /** @@ -29,6 +32,51 @@ 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 two methods: + * + * - `scheduleNotification`: Schedule a notification in the future. + * + * @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: new Date().toISOString(), + request: { + method: 'fireNotification', + }, + }, + }); + case 'cancelNotification': + return snap.request({ + method: 'snap_cancelBackgroundEvent', + params: { + id: request.params?.id, + }, + }); default: // eslint-disable-next-line @typescript-eslint/no-throw-literal throw new MethodNotFoundError({ method: request.method }); diff --git a/packages/snaps-sdk/src/types/methods/methods.ts b/packages/snaps-sdk/src/types/methods/methods.ts index cf671a6254..d1711e2118 100644 --- a/packages/snaps-sdk/src/types/methods/methods.ts +++ b/packages/snaps-sdk/src/types/methods/methods.ts @@ -1,9 +1,17 @@ import type { Method } from '../../internals'; +import type { + CancelBackgroundEventParams, + CancelBackgroundEventResult, +} from './cancel-background-event'; 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, @@ -56,6 +64,10 @@ import type { ResolveInterfaceParams, ResolveInterfaceResult, } from './resolve-interface'; +import type { + ScheduleBackgroundEventParams, + ScheduleBackgroundEventResult, +} from './schedule-background-event'; import type { UpdateInterfaceParams, UpdateInterfaceResult, @@ -80,6 +92,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]; From 48a543ddfeeccca00d331022986c4a6320db1415 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Wed, 11 Dec 2024 14:19:39 -0500 Subject: [PATCH 11/13] fix type and coverage --- packages/examples/packages/cronjobs/src/index.ts | 2 +- packages/snaps-controllers/coverage.json | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/examples/packages/cronjobs/src/index.ts b/packages/examples/packages/cronjobs/src/index.ts index 2a7f5621db..98a5634ef3 100644 --- a/packages/examples/packages/cronjobs/src/index.ts +++ b/packages/examples/packages/cronjobs/src/index.ts @@ -74,7 +74,7 @@ export const onRpcRequest: OnRpcRequestHandler = async ({ request }) => { return snap.request({ method: 'snap_cancelBackgroundEvent', params: { - id: request.params?.id, + id: (request.params as Record).id, }, }); default: diff --git a/packages/snaps-controllers/coverage.json b/packages/snaps-controllers/coverage.json index 31e59ff24e..1a634a646c 100644 --- a/packages/snaps-controllers/coverage.json +++ b/packages/snaps-controllers/coverage.json @@ -1,6 +1,6 @@ { - "branches": 92.8, - "functions": 95.25, - "lines": 97.76, - "statements": 97.43 + "branches": 92.98, + "functions": 95.98, + "lines": 97.97, + "statements": 97.63 } From 409b4aadf1b100d64d2d4b7ab0863baa86cc2426 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Wed, 11 Dec 2024 14:43:39 -0500 Subject: [PATCH 12/13] rebuild snap --- packages/examples/packages/cronjobs/snap.manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/examples/packages/cronjobs/snap.manifest.json b/packages/examples/packages/cronjobs/snap.manifest.json index e40e3b2509..91bb0737d9 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": "z06MC0KYtySDOdGTKH+afyyu3GWBu4LnKc4FVvfq1Oo=", + "shasum": "Q7CF9RlmHn1giz9R7wJR0MYCndQsgdXv0++whuLLm8Y=", "location": { "npm": { "filePath": "dist/bundle.js", From 663f4fb7697f5bc6841bd9be5b9b1cb09d800e0a Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Wed, 11 Dec 2024 18:50:46 -0500 Subject: [PATCH 13/13] update test-snaps --- .../packages/cronjobs/snap.manifest.json | 2 +- .../examples/packages/cronjobs/src/index.ts | 6 +- .../src/features/snaps/cronjobs/Cronjobs.tsx | 9 ++- .../components/CancelBackgroundEvent.tsx | 52 ++++++++++++++++ .../components/GetBackgroundEvents.tsx | 39 ++++++++++++ .../components/ScheduleBackgroundEvent.tsx | 59 +++++++++++++++++++ 6 files changed, 164 insertions(+), 3 deletions(-) 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 diff --git a/packages/examples/packages/cronjobs/snap.manifest.json b/packages/examples/packages/cronjobs/snap.manifest.json index 91bb0737d9..d95477003d 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": "Q7CF9RlmHn1giz9R7wJR0MYCndQsgdXv0++whuLLm8Y=", + "shasum": "+Y2G5jljVTKefA0vWgTQ+k4QLaC4uyLI6aMunBPFbOg=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/examples/packages/cronjobs/src/index.ts b/packages/examples/packages/cronjobs/src/index.ts index 98a5634ef3..de9c02479b 100644 --- a/packages/examples/packages/cronjobs/src/index.ts +++ b/packages/examples/packages/cronjobs/src/index.ts @@ -64,7 +64,7 @@ export const onRpcRequest: OnRpcRequestHandler = async ({ request }) => { return snap.request({ method: 'snap_scheduleBackgroundEvent', params: { - date: new Date().toISOString(), + date: (request.params as Record).date, request: { method: 'fireNotification', }, @@ -77,6 +77,10 @@ export const onRpcRequest: OnRpcRequestHandler = async ({ request }) => { id: (request.params as Record).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/test-snaps/src/features/snaps/cronjobs/Cronjobs.tsx b/packages/test-snaps/src/features/snaps/cronjobs/Cronjobs.tsx index 4bf6e8bbd6..98f50840d2 100644 --- a/packages/test-snaps/src/features/snaps/cronjobs/Cronjobs.tsx +++ b/packages/test-snaps/src/features/snaps/cronjobs/Cronjobs.tsx @@ -1,6 +1,9 @@ import type { FunctionComponent } from 'react'; import { Snap } from '../../../components'; +import { CancelBackgroundEvent } from './components/CancelBackgroundEvent'; +import { GetBackgroundEvents } from './components/GetBackgroundEvents'; +import { ScheduleBackgroundEvent } from './components/ScheduleBackgroundEvent'; import { CRONJOBS_SNAP_ID, CRONJOBS_SNAP_PORT, @@ -15,6 +18,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)} + + + + ); +};