From e3f6fb9a4fd377c4e3672d4c9a0445babc0bfe09 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Tue, 3 Dec 2024 09:58:03 -0500 Subject: [PATCH 01/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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)} + + + + ); +}; From 655d7b55ed633fc1740676b89069d38e3575f63a Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Thu, 12 Dec 2024 15:17:08 -0500 Subject: [PATCH 14/38] address PR comments --- .../examples/packages/cronjobs/src/index.ts | 2 + packages/snaps-controllers/package.json | 2 + .../src/cronjob/CronjobController.test.ts | 48 ++++++------- .../src/cronjob/CronjobController.ts | 21 +++--- .../src/endowments/cronjob.test.ts | 11 --- .../src/endowments/cronjob.ts | 10 ++- .../permitted/cancelBackgroundEvent.test.ts | 63 +++++++++++++++++ .../src/permitted/cancelBackgroundEvent.ts | 16 ++++- .../src/permitted/getBackgroundEvents.test.ts | 67 ++++++++++++++++++- .../src/permitted/getBackgroundEvents.ts | 21 ++++-- .../permitted/scheduleBackgroundEvent.test.ts | 61 +++++++++++++++-- .../src/permitted/scheduleBackgroundEvent.ts | 10 ++- .../types/methods/cancel-background-event.ts | 10 +++ .../types/methods/get-background-events.ts | 8 ++- .../methods/schedule-background-event.ts | 7 +- .../src/features/snaps/cronjobs/Cronjobs.tsx | 8 ++- .../snaps/cronjobs/components/index.ts | 3 + yarn.lock | 2 + 18 files changed, 302 insertions(+), 68 deletions(-) create mode 100644 packages/test-snaps/src/features/snaps/cronjobs/components/index.ts diff --git a/packages/examples/packages/cronjobs/src/index.ts b/packages/examples/packages/cronjobs/src/index.ts index de9c02479b..35cc29ccfa 100644 --- a/packages/examples/packages/cronjobs/src/index.ts +++ b/packages/examples/packages/cronjobs/src/index.ts @@ -51,6 +51,8 @@ export const onCronjob: OnCronjobHandler = async ({ request }) => { * `wallet_invokeSnap` method. This handler handles two methods: * * - `scheduleNotification`: Schedule a notification in the future. + * - `cancelNotification`: Cancel a notification. + * - `getBackgroundEvents`: Get the Snap's background events. * * @param params - The request parameters. * @param params.request - The JSON-RPC request object. diff --git a/packages/snaps-controllers/package.json b/packages/snaps-controllers/package.json index b865a31ac0..9d3fecacbd 100644 --- a/packages/snaps-controllers/package.json +++ b/packages/snaps-controllers/package.json @@ -100,6 +100,7 @@ "fast-deep-equal": "^3.1.3", "get-npm-tarball-url": "^2.0.3", "immer": "^9.0.6", + "luxon": "^3.5.0", "nanoid": "^3.1.31", "readable-stream": "^3.6.2", "readable-web-to-node-stream": "^3.0.2", @@ -124,6 +125,7 @@ "@types/concat-stream": "^2.0.0", "@types/gunzip-maybe": "^1.4.0", "@types/jest": "^27.5.1", + "@types/luxon": "^3", "@types/mocha": "^10.0.1", "@types/node": "18.14.2", "@types/readable-stream": "^4.0.15", diff --git a/packages/snaps-controllers/src/cronjob/CronjobController.test.ts b/packages/snaps-controllers/src/cronjob/CronjobController.test.ts index 5c64c5863f..83925bed4f 100644 --- a/packages/snaps-controllers/src/cronjob/CronjobController.test.ts +++ b/packages/snaps-controllers/src/cronjob/CronjobController.test.ts @@ -18,7 +18,7 @@ describe('CronjobController', () => { const originalProcessNextTick = process.nextTick; beforeEach(() => { - jest.useFakeTimers().setSystemTime(new Date('2022-01-01')); + jest.useFakeTimers().setSystemTime(new Date('2022-01-01T00:00Z')); }); afterAll(() => { @@ -255,7 +255,7 @@ describe('CronjobController', () => { const backgroundEvent = { snapId: MOCK_SNAP_ID, - date: '2022-01-01T01:00', + date: '2022-01-01T01:00Z', request: { method: 'handleEvent', params: ['p1'], @@ -299,7 +299,7 @@ describe('CronjobController', () => { const backgroundEvent = { snapId: MOCK_SNAP_ID, - date: '2022-01-01T01:00', + date: '2022-01-01T01:00Z', request: { method: 'handleEvent', params: ['p1'], @@ -312,7 +312,7 @@ describe('CronjobController', () => { [id]: { id, scheduledAt: expect.any(String), ...backgroundEvent }, }); - cronjobController.cancelBackgroundEvent(id, MOCK_SNAP_ID); + cronjobController.cancelBackgroundEvent(MOCK_SNAP_ID, id); jest.advanceTimersByTime(inMilliseconds(1, Duration.Day)); @@ -345,7 +345,7 @@ describe('CronjobController', () => { const backgroundEvent = { snapId: MOCK_SNAP_ID, - date: '2022-01-01T01:00', + date: '2022-01-01T01:00Z', request: { method: 'handleEvent', params: ['p1'], @@ -358,7 +358,7 @@ describe('CronjobController', () => { [id]: { id, scheduledAt: expect.any(String), ...backgroundEvent }, }); - expect(() => cronjobController.cancelBackgroundEvent(id, 'foo')).toThrow( + expect(() => cronjobController.cancelBackgroundEvent('foo', id)).toThrow( 'Only the origin that scheduled this event can cancel it', ); @@ -376,7 +376,7 @@ describe('CronjobController', () => { const backgroundEvent = { snapId: MOCK_SNAP_ID, - date: '2022-01-01T01:00', + date: '2022-01-01T01:00Z', request: { method: 'handleEvent', params: ['p1'], @@ -390,7 +390,7 @@ describe('CronjobController', () => { { id, snapId: MOCK_SNAP_ID, - date: '2022-01-01T01:00', + date: '2022-01-01T01:00Z', request: { method: 'handleEvent', params: ['p1'], @@ -416,7 +416,7 @@ describe('CronjobController', () => { id: 'foo', scheduledAt: new Date().toISOString(), snapId: MOCK_SNAP_ID, - date: '2022-01-01T01:00', + date: '2022-01-01T01:00Z', request: { method: 'handleEvent', params: ['p1'], @@ -502,7 +502,7 @@ describe('CronjobController', () => { id: 'foo', scheduledAt: new Date().toISOString(), snapId: MOCK_SNAP_ID, - date: '2022-01-01T01:00', + date: '2022-01-01T01:00Z', request: { method: 'handleEvent', params: ['p1'], @@ -512,7 +512,7 @@ describe('CronjobController', () => { id: 'bar', scheduledAt: new Date().toISOString(), snapId: MOCK_SNAP_ID, - date: '2021-01-01T01:00', + date: '2021-01-01T01:00Z', request: { method: 'handleEvent', params: ['p1'], @@ -537,7 +537,7 @@ describe('CronjobController', () => { id: 'foo', scheduledAt: new Date().toISOString(), snapId: MOCK_SNAP_ID, - date: '2022-01-01T01:00', + date: '2022-01-01T01:00Z', request: { method: 'handleEvent', params: ['p1'], @@ -596,7 +596,7 @@ describe('CronjobController', () => { cronjobController.scheduleBackgroundEvent({ snapId: MOCK_SNAP_ID, - date: '2022-01-01T01:00', + date: '2022-01-01T01:00Z', request: { method: 'handleEvent', params: ['p1'], @@ -665,7 +665,7 @@ describe('CronjobController', () => { const id = cronjobController.scheduleBackgroundEvent({ snapId: MOCK_SNAP_ID, - date: '2022-01-01T01:00', + date: '2022-01-01T01:00Z', request: { method: 'handleEvent', params: ['p1'], @@ -715,7 +715,7 @@ describe('CronjobController', () => { id, scheduledAt: expect.any(String), snapId: MOCK_SNAP_ID, - date: '2022-01-01T01:00', + date: '2022-01-01T01:00Z', request: { method: 'handleEvent', params: ['p1'], @@ -740,7 +740,7 @@ describe('CronjobController', () => { id: 'foo', scheduledAt: new Date().toISOString(), snapId: MOCK_SNAP_ID, - date: '2022-01-01T01:00', + date: '2022-01-01T01:00Z', request: { method: 'handleEvent', params: ['p1'], @@ -857,7 +857,7 @@ describe('CronjobController', () => { 'CronjobController:scheduleBackgroundEvent', { snapId: MOCK_SNAP_ID, - date: '2022-01-01T01:00', + date: '2022-01-01T01:00Z', request: { method: 'handleExport', params: ['p1'], @@ -870,7 +870,7 @@ describe('CronjobController', () => { id, snapId: MOCK_SNAP_ID, scheduledAt: expect.any(String), - date: '2022-01-01T01:00', + date: '2022-01-01T01:00Z', request: { method: 'handleExport', params: ['p1'], @@ -915,7 +915,7 @@ describe('CronjobController', () => { 'CronjobController:scheduleBackgroundEvent', { snapId: MOCK_SNAP_ID, - date: '2022-01-01T01:00', + date: '2022-01-01T01:00Z', request: { method: 'handleExport', params: ['p1'], @@ -928,7 +928,7 @@ describe('CronjobController', () => { id, snapId: MOCK_SNAP_ID, scheduledAt: expect.any(String), - date: '2022-01-01T01:00', + date: '2022-01-01T01:00Z', request: { method: 'handleExport', params: ['p1'], @@ -938,8 +938,8 @@ describe('CronjobController', () => { rootMessenger.call( 'CronjobController:cancelBackgroundEvent', - id, MOCK_SNAP_ID, + id, ); expect(cronjobController.state.events).toStrictEqual({}); @@ -964,7 +964,7 @@ describe('CronjobController', () => { 'CronjobController:scheduleBackgroundEvent', { snapId: MOCK_SNAP_ID, - date: '2022-01-01T01:00', + date: '2022-01-01T01:00Z', request: { method: 'handleExport', params: ['p1'], @@ -977,7 +977,7 @@ describe('CronjobController', () => { id, snapId: MOCK_SNAP_ID, scheduledAt: expect.any(String), - date: '2022-01-01T01:00', + date: '2022-01-01T01:00Z', request: { method: 'handleExport', params: ['p1'], @@ -995,7 +995,7 @@ describe('CronjobController', () => { id, snapId: MOCK_SNAP_ID, scheduledAt: expect.any(String), - date: '2022-01-01T01:00', + date: '2022-01-01T01:00Z', request: { method: 'handleExport', params: ['p1'], diff --git a/packages/snaps-controllers/src/cronjob/CronjobController.ts b/packages/snaps-controllers/src/cronjob/CronjobController.ts index 81e8091e8b..b8d88f1617 100644 --- a/packages/snaps-controllers/src/cronjob/CronjobController.ts +++ b/packages/snaps-controllers/src/cronjob/CronjobController.ts @@ -21,6 +21,7 @@ import { } from '@metamask/snaps-utils'; import { assert, Duration, inMilliseconds } from '@metamask/utils'; import { castDraft } from 'immer'; +import { DateTime } from 'luxon'; import { nanoid } from 'nanoid'; import type { @@ -222,11 +223,7 @@ export class CronjobController extends BaseController< logError(error); }); - this.#rescheduleBackgroundEvents(Object.values(this.state.events)).catch( - (error) => { - logError(error); - }, - ); + this.#rescheduleBackgroundEvents(Object.values(this.state.events)); } /** @@ -343,10 +340,14 @@ export class CronjobController extends BaseController< scheduleBackgroundEvent( backgroundEventWithoutId: Omit, ) { + // removing minute precision and converting to UTC. + const scheduledAt = DateTime.fromJSDate(new Date()) + .toUTC() + .toFormat("yyyy-MM-dd'T'HH:mm'Z'"); const event = { ...backgroundEventWithoutId, id: nanoid(), - scheduledAt: new Date().toISOString(), + scheduledAt, }; this.#setUpBackgroundEvent(event); this.update((state) => { @@ -359,11 +360,11 @@ 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. + * @param id - The id of the background event to cancel. * @throws If the event does not exist. */ - cancelBackgroundEvent(id: string, origin: string) { + cancelBackgroundEvent(origin: string, id: string) { assert( this.state.events[id], `A background event with the id of "${id}" does not exist.`, @@ -515,7 +516,7 @@ export class CronjobController extends BaseController< * * @param backgroundEvents - A list of background events to reschdule. */ - async #rescheduleBackgroundEvents(backgroundEvents: BackgroundEvent[]) { + #rescheduleBackgroundEvents(backgroundEvents: BackgroundEvent[]) { for (const snapEvent of backgroundEvents) { const { date } = snapEvent; const now = new Date(); @@ -591,7 +592,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); this.register(snap.id); } diff --git a/packages/snaps-rpc-methods/src/endowments/cronjob.test.ts b/packages/snaps-rpc-methods/src/endowments/cronjob.test.ts index 632ce04326..35c5d91113 100644 --- a/packages/snaps-rpc-methods/src/endowments/cronjob.test.ts +++ b/packages/snaps-rpc-methods/src/endowments/cronjob.test.ts @@ -82,17 +82,6 @@ describe('validateCronjobCaveat', () => { expect(() => validateCronjobCaveat(caveat)).not.toThrow(); }); - it('should throw if caveat has no proper value', () => { - const caveat: Caveat = { - type: SnapCaveatType.SnapCronjob, - value: {}, - }; - - expect(() => validateCronjobCaveat(caveat)).toThrow( - `Expected a plain object.`, - ); - }); - it('should throw an error when cron specification is missing', () => { const caveat: Caveat = { type: SnapCaveatType.SnapCronjob, diff --git a/packages/snaps-rpc-methods/src/endowments/cronjob.ts b/packages/snaps-rpc-methods/src/endowments/cronjob.ts index 380b98409f..527a95e96a 100644 --- a/packages/snaps-rpc-methods/src/endowments/cronjob.ts +++ b/packages/snaps-rpc-methods/src/endowments/cronjob.ts @@ -96,7 +96,7 @@ export function getCronjobCaveatJobs( const caveat = permission.caveats[0] as Caveat; - return (caveat.value?.jobs as CronjobSpecification[]) ?? null; + return (caveat.value?.jobs as CronjobSpecification[]) ?? []; } /** @@ -116,13 +116,17 @@ export function validateCronjobCaveat(caveat: Caveat) { const { value } = caveat; - if (!hasProperty(value, 'jobs') || !isPlainObject(value)) { + if (!isPlainObject(value)) { throw rpcErrors.invalidParams({ message: 'Expected a plain object.', }); } - if (!isCronjobSpecificationArray(value.jobs)) { + const valueKeys = Object.keys(value); + + // If it's an empty object, ok to skip validation as this indicates + // intention to use the background event rpc methods + if (valueKeys.length !== 0 && !isCronjobSpecificationArray(value.jobs)) { throw rpcErrors.invalidParams({ message: 'Expected a valid cronjob specification array.', }); diff --git a/packages/snaps-rpc-methods/src/permitted/cancelBackgroundEvent.test.ts b/packages/snaps-rpc-methods/src/permitted/cancelBackgroundEvent.test.ts index cbf506a021..6c51087b77 100644 --- a/packages/snaps-rpc-methods/src/permitted/cancelBackgroundEvent.test.ts +++ b/packages/snaps-rpc-methods/src/permitted/cancelBackgroundEvent.test.ts @@ -3,8 +3,10 @@ import type { CancelBackgroundEventParams, CancelBackgroundEventResult, } from '@metamask/snaps-sdk'; +import { MOCK_SNAP_ID } from '@metamask/snaps-utils/test-utils'; import type { JsonRpcRequest, PendingJsonRpcResponse } from '@metamask/utils'; +import { SnapEndowments } from '../endowments'; import { cancelBackgroundEventHandler } from './cancelBackgroundEvent'; describe('snap_cancelBackgroundEvent', () => { @@ -21,17 +23,26 @@ describe('snap_cancelBackgroundEvent', () => { }); describe('implementation', () => { + const createOriginMiddleware = + (origin: string) => + (request: any, _response: unknown, next: () => void, _end: unknown) => { + request.origin = origin; + next(); + }; it('returns null after calling the `scheduleBackgroundEvent` hook', async () => { const { implementation } = cancelBackgroundEventHandler; const cancelBackgroundEvent = jest.fn(); + const hasPermission = jest.fn().mockImplementation(() => true); const hooks = { cancelBackgroundEvent, + hasPermission, }; const engine = new JsonRpcEngine(); + engine.push(createOriginMiddleware(MOCK_SNAP_ID)); engine.push((request, response, next, end) => { const result = implementation( request as JsonRpcRequest, @@ -60,13 +71,16 @@ describe('snap_cancelBackgroundEvent', () => { const { implementation } = cancelBackgroundEventHandler; const cancelBackgroundEvent = jest.fn(); + const hasPermission = jest.fn().mockImplementation(() => true); const hooks = { cancelBackgroundEvent, + hasPermission, }; const engine = new JsonRpcEngine(); + engine.push(createOriginMiddleware(MOCK_SNAP_ID)); engine.push((request, response, next, end) => { const result = implementation( request as JsonRpcRequest, @@ -91,17 +105,66 @@ describe('snap_cancelBackgroundEvent', () => { expect(cancelBackgroundEvent).toHaveBeenCalledWith('foo'); }); + it('throws if a snap does not have the "endowment:cronjob" permission', async () => { + const { implementation } = cancelBackgroundEventHandler; + + const cancelBackgroundEvent = jest.fn(); + const hasPermission = jest.fn().mockImplementation(() => false); + + const hooks = { + cancelBackgroundEvent, + hasPermission, + }; + + const engine = new JsonRpcEngine(); + + engine.push(createOriginMiddleware(MOCK_SNAP_ID)); + engine.push((request, response, next, end) => { + const result = implementation( + request as JsonRpcRequest, + response as PendingJsonRpcResponse, + next, + end, + hooks, + ); + + result?.catch(end); + }); + + const response = await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: 'snap_cancelBackgroundEvent', + params: { + id: 2, + }, + }); + + expect(response).toStrictEqual({ + error: { + code: -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 } = cancelBackgroundEventHandler; const cancelBackgroundEvent = jest.fn(); + const hasPermission = jest.fn().mockImplementation(() => true); const hooks = { cancelBackgroundEvent, + hasPermission, }; const engine = new JsonRpcEngine(); + engine.push(createOriginMiddleware(MOCK_SNAP_ID)); engine.push((request, response, next, end) => { const result = implementation( request as JsonRpcRequest, diff --git a/packages/snaps-rpc-methods/src/permitted/cancelBackgroundEvent.ts b/packages/snaps-rpc-methods/src/permitted/cancelBackgroundEvent.ts index 4976a6b468..1f02d4daff 100644 --- a/packages/snaps-rpc-methods/src/permitted/cancelBackgroundEvent.ts +++ b/packages/snaps-rpc-methods/src/permitted/cancelBackgroundEvent.ts @@ -10,16 +10,19 @@ import { type InferMatching } from '@metamask/snaps-utils'; import { StructError, create, object, string } from '@metamask/superstruct'; import { type PendingJsonRpcResponse } from '@metamask/utils'; +import { SnapEndowments } from '../endowments'; import type { MethodHooksObject } from '../utils'; const methodName = 'snap_cancelBackgroundEvent'; const hookNames: MethodHooksObject = { cancelBackgroundEvent: true, + hasPermission: true, }; export type CancelBackgroundEventMethodHooks = { cancelBackgroundEvent: (id: string) => void; + hasPermission: (permissionName: string) => boolean; }; export const cancelBackgroundEventHandler: PermittedHandlerExport< @@ -51,6 +54,7 @@ export type CancelBackgroundEventParameters = InferMatching< * @param end - The `json-rpc-engine` "end" callback. * @param hooks - The RPC method hooks. * @param hooks.cancelBackgroundEvent - The function to cancel a background event. + * @param hooks.hasPermission - The function to check if a snap has the `endowment:cronjob` permission. * @returns Nothing. */ async function getCancelBackgroundEventImplementation( @@ -58,9 +62,17 @@ async function getCancelBackgroundEventImplementation( res: PendingJsonRpcResponse, _next: unknown, end: JsonRpcEngineEndCallback, - { cancelBackgroundEvent }: CancelBackgroundEventMethodHooks, + { cancelBackgroundEvent, hasPermission }: CancelBackgroundEventMethodHooks, ): 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); diff --git a/packages/snaps-rpc-methods/src/permitted/getBackgroundEvents.test.ts b/packages/snaps-rpc-methods/src/permitted/getBackgroundEvents.test.ts index 682ec51914..318d8acea6 100644 --- a/packages/snaps-rpc-methods/src/permitted/getBackgroundEvents.test.ts +++ b/packages/snaps-rpc-methods/src/permitted/getBackgroundEvents.test.ts @@ -6,6 +6,7 @@ import type { import { MOCK_SNAP_ID } from '@metamask/snaps-utils/test-utils'; import type { JsonRpcRequest, PendingJsonRpcResponse } from '@metamask/utils'; +import { SnapEndowments } from '../endowments'; import { getBackgroundEventsHandler } from './getBackgroundEvents'; describe('snap_getBackgroundEvents', () => { @@ -22,6 +23,12 @@ describe('snap_getBackgroundEvents', () => { }); describe('implementation', () => { + const createOriginMiddleware = + (origin: string) => + (request: any, _response: unknown, next: () => void, _end: unknown) => { + request.origin = origin; + next(); + }; it('returns an array of background events after calling the `getBackgroundEvents` hook', async () => { const { implementation } = getBackgroundEventsHandler; @@ -29,7 +36,7 @@ describe('snap_getBackgroundEvents', () => { { id: 'foo', snapId: MOCK_SNAP_ID, - date: '2022-01-01T01:00', + date: '2022-01-01T01:00Z', scheduledAt: '2021-01-01', request: { method: 'handleExport', @@ -42,12 +49,16 @@ describe('snap_getBackgroundEvents', () => { .fn() .mockImplementation(() => backgroundEvents); + const hasPermission = jest.fn().mockImplementation(() => true); + const hooks = { getBackgroundEvents, + hasPermission, }; const engine = new JsonRpcEngine(); + engine.push(createOriginMiddleware(MOCK_SNAP_ID)); engine.push((request, response, next, end) => { const result = implementation( request as JsonRpcRequest, @@ -78,12 +89,16 @@ describe('snap_getBackgroundEvents', () => { const getBackgroundEvents = jest.fn(); + const hasPermission = jest.fn().mockImplementation(() => true); + const hooks = { getBackgroundEvents, + hasPermission, }; const engine = new JsonRpcEngine(); + engine.push(createOriginMiddleware(MOCK_SNAP_ID)); engine.push((request, response, next, end) => { const result = implementation( request as JsonRpcRequest, @@ -112,12 +127,16 @@ describe('snap_getBackgroundEvents', () => { throw new Error('foobar'); }); + const hasPermission = jest.fn().mockImplementation(() => true); + const hooks = { getBackgroundEvents, + hasPermission, }; const engine = new JsonRpcEngine(); + engine.push(createOriginMiddleware(MOCK_SNAP_ID)); engine.push((request, response, next, end) => { const result = implementation( request as JsonRpcRequest, @@ -150,5 +169,51 @@ describe('snap_getBackgroundEvents', () => { jsonrpc: '2.0', }); }); + + it('throws if a snap does not have the "endowment:cronjob" permission', async () => { + const { implementation } = getBackgroundEventsHandler; + + const getBackgroundEvents = jest.fn(); + const hasPermission = jest.fn().mockImplementation(() => false); + + const hooks = { + getBackgroundEvents, + hasPermission, + }; + + const engine = new JsonRpcEngine(); + + engine.push(createOriginMiddleware(MOCK_SNAP_ID)); + engine.push((request, response, next, end) => { + const result = implementation( + request as JsonRpcRequest, + response as PendingJsonRpcResponse, + next, + end, + hooks, + ); + + result?.catch(end); + }); + + const response = await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: 'snap_cancelBackgroundEvent', + params: { + id: 2, + }, + }); + + expect(response).toStrictEqual({ + error: { + code: -32600, + message: `The snap "${MOCK_SNAP_ID}" does not have the "${SnapEndowments.Cronjob}" permission.`, + stack: expect.any(String), + }, + id: 1, + jsonrpc: '2.0', + }); + }); }); }); diff --git a/packages/snaps-rpc-methods/src/permitted/getBackgroundEvents.ts b/packages/snaps-rpc-methods/src/permitted/getBackgroundEvents.ts index f94e78e470..4e0aeb55be 100644 --- a/packages/snaps-rpc-methods/src/permitted/getBackgroundEvents.ts +++ b/packages/snaps-rpc-methods/src/permitted/getBackgroundEvents.ts @@ -1,5 +1,6 @@ import type { JsonRpcEngineEndCallback } from '@metamask/json-rpc-engine'; import type { PermittedHandlerExport } from '@metamask/permission-controller'; +import { rpcErrors } from '@metamask/rpc-errors'; import type { BackgroundEvent, GetBackgroundEventsParams, @@ -8,16 +9,19 @@ import type { } from '@metamask/snaps-sdk'; import { type PendingJsonRpcResponse } from '@metamask/utils'; +import { SnapEndowments } from '../endowments'; import type { MethodHooksObject } from '../utils'; const methodName = 'snap_getBackgroundEvents'; const hookNames: MethodHooksObject = { getBackgroundEvents: true, + hasPermission: true, }; export type GetBackgroundEventsMethodHooks = { getBackgroundEvents: () => BackgroundEvent[]; + hasPermission: (permissionName: string) => boolean; }; export const getBackgroundEventsHandler: PermittedHandlerExport< @@ -33,23 +37,32 @@ export const getBackgroundEventsHandler: PermittedHandlerExport< /** * The `snap_getBackgroundEvents` method implementation. * - * @param _req - The JSON-RPC request object. Not used by this - * function. + * @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.getBackgroundEvents - The function to get the background events. + * @param hooks.hasPermission - The function to check if a snap has the `endowment:cronjob` permission. * @returns An array of background events. */ async function getGetBackgroundEventsImplementation( - _req: JsonRpcRequest, + req: JsonRpcRequest, res: PendingJsonRpcResponse, _next: unknown, end: JsonRpcEngineEndCallback, - { getBackgroundEvents }: GetBackgroundEventsMethodHooks, + { getBackgroundEvents, hasPermission }: GetBackgroundEventsMethodHooks, ): Promise { + const { 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 events = getBackgroundEvents(); res.result = events; diff --git a/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.test.ts b/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.test.ts index e563510489..19af3a2f8a 100644 --- a/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.test.ts +++ b/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.test.ts @@ -62,7 +62,7 @@ describe('snap_scheduleBackgroundEvent', () => { id: 1, method: 'snap_scheduleBackgroundEvent', params: { - date: '2022-01-01T01:00', + date: '2022-01-01T01:00Z', request: { method: 'handleExport', params: ['p1'], @@ -73,7 +73,7 @@ describe('snap_scheduleBackgroundEvent', () => { expect(response).toStrictEqual({ jsonrpc: '2.0', id: 1, result: 'foo' }); }); - it('schedules a background event', async () => { + it('schedules a background event with minute precision', async () => { const { implementation } = scheduleBackgroundEventHandler; const scheduleBackgroundEvent = jest.fn(); @@ -104,7 +104,7 @@ describe('snap_scheduleBackgroundEvent', () => { id: 1, method: 'snap_scheduleBackgroundEvent', params: { - date: '2022-01-01T01:00', + date: '2022-01-01T01:00:35Z', request: { method: 'handleExport', params: ['p1'], @@ -113,7 +113,7 @@ describe('snap_scheduleBackgroundEvent', () => { }); expect(scheduleBackgroundEvent).toHaveBeenCalledWith({ - date: '2022-01-01T01:00', + date: '2021-12-31T20:00:00.000-05:00', request: { method: 'handleExport', params: ['p1'], @@ -152,7 +152,7 @@ describe('snap_scheduleBackgroundEvent', () => { id: 1, method: 'snap_scheduleBackgroundEvent', params: { - date: 'foobar', + date: '2022-01-01T01:00Z', request: { method: 'handleExport', params: ['p1'], @@ -171,6 +171,57 @@ describe('snap_scheduleBackgroundEvent', () => { }); }); + it('throws if no timezone information is provided in the ISO8601 string', async () => { + const { implementation } = scheduleBackgroundEventHandler; + + const scheduleBackgroundEvent = jest.fn(); + const hasPermission = jest.fn().mockImplementation(() => true); + + const hooks = { + scheduleBackgroundEvent, + hasPermission, + }; + + const engine = new JsonRpcEngine(); + + engine.push(createOriginMiddleware(MOCK_SNAP_ID)); + engine.push((request, response, next, end) => { + const result = implementation( + request as JsonRpcRequest, + response as PendingJsonRpcResponse, + next, + end, + hooks, + ); + + result?.catch(end); + }); + + const response = await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: 'snap_scheduleBackgroundEvent', + params: { + date: '2022-01-01T01:00', + request: { + method: 'handleExport', + params: ['p1'], + }, + }, + }); + + expect(response).toStrictEqual({ + error: { + code: -32602, + message: + 'Invalid params: At path: date -- ISO8601 string must have timezone information.', + stack: expect.any(String), + }, + id: 1, + jsonrpc: '2.0', + }); + }); + it('throws on invalid params', async () => { const { implementation } = scheduleBackgroundEventHandler; diff --git a/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.ts b/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.ts index a19d440fed..0b0a8183c6 100644 --- a/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.ts +++ b/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.ts @@ -58,6 +58,9 @@ const ScheduleBackgroundEventsParametersStruct = object({ date: refine(string(), 'date', (val) => { const date = DateTime.fromISO(val); if (date.isValid) { + if (!val.endsWith('Z') || date.offset === 0) { + return 'ISO8601 string must have timezone information'; + } return true; } return 'Not a valid ISO8601 string'; @@ -108,7 +111,12 @@ async function getScheduleBackgroundEventImplementation( const { date, request } = validatedParams; - const id = scheduleBackgroundEvent({ date, request }); + // make sure any second/millisecond precision is removed. + const truncatedDate = DateTime.fromISO(date) + .startOf('minute') + .toISO() as string; + + const id = scheduleBackgroundEvent({ date: truncatedDate, request }); res.result = id; } catch (error) { return end(error); diff --git a/packages/snaps-sdk/src/types/methods/cancel-background-event.ts b/packages/snaps-sdk/src/types/methods/cancel-background-event.ts index 1121312784..c0552293f9 100644 --- a/packages/snaps-sdk/src/types/methods/cancel-background-event.ts +++ b/packages/snaps-sdk/src/types/methods/cancel-background-event.ts @@ -1,5 +1,15 @@ +/** + * The request parameters for the `snap_cancelBackgroundEvent` method. + * + * @property id - The id of the background event to cancel. + */ export type CancelBackgroundEventParams = { id: string; }; +/** + * The result returned for the `snap_cancelBackgroundEvent` method. + * + * This method does not return anything. + */ export type CancelBackgroundEventResult = null; diff --git a/packages/snaps-sdk/src/types/methods/get-background-events.ts b/packages/snaps-sdk/src/types/methods/get-background-events.ts index b35240bab6..eb281d6232 100644 --- a/packages/snaps-sdk/src/types/methods/get-background-events.ts +++ b/packages/snaps-sdk/src/types/methods/get-background-events.ts @@ -19,11 +19,15 @@ export type BackgroundEvent = { }; /** - * `snap_getBackgroundEvents` result type. + * The result returned by the `snap_getBackgroundEvents` method. + * + * It consists of an array background events (if any) for a snap. */ export type GetBackgroundEventsResult = BackgroundEvent[]; /** - * `snao_getBackgroundEvents` params. + * The request parameters for the `snap_getBackgroundEvents` method. + * + * This method does not accept any parameters. */ export type GetBackgroundEventsParams = never; diff --git a/packages/snaps-sdk/src/types/methods/schedule-background-event.ts b/packages/snaps-sdk/src/types/methods/schedule-background-event.ts index 1bb07bb969..40c76e2abe 100644 --- a/packages/snaps-sdk/src/types/methods/schedule-background-event.ts +++ b/packages/snaps-sdk/src/types/methods/schedule-background-event.ts @@ -1,7 +1,10 @@ import type { Cronjob } from '../permissions'; /** - * Params for the `snap_scheduleBackgroundEvent` method. + * The request parameters for the `snap_scheduleBackgroundEvent` method. + * + * @property date - The ISO8601 date of when to fire the background event. + * @property request - The request to be called when the event fires. */ export type ScheduleBackgroundEventParams = { date: string; @@ -9,6 +12,6 @@ export type ScheduleBackgroundEventParams = { }; /** - * `snap_scheduleBackgroundEvent` return type. + * The result returned by the `snap_scheduleBackgroundEvent` method. */ export type ScheduleBackgroundEventResult = string; diff --git a/packages/test-snaps/src/features/snaps/cronjobs/Cronjobs.tsx b/packages/test-snaps/src/features/snaps/cronjobs/Cronjobs.tsx index 98f50840d2..e5e2bb273b 100644 --- a/packages/test-snaps/src/features/snaps/cronjobs/Cronjobs.tsx +++ b/packages/test-snaps/src/features/snaps/cronjobs/Cronjobs.tsx @@ -1,9 +1,11 @@ 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 { + CancelBackgroundEvent, + ScheduleBackgroundEvent, + GetBackgroundEvents, +} from './components'; import { CRONJOBS_SNAP_ID, CRONJOBS_SNAP_PORT, diff --git a/packages/test-snaps/src/features/snaps/cronjobs/components/index.ts b/packages/test-snaps/src/features/snaps/cronjobs/components/index.ts new file mode 100644 index 0000000000..03bef54e84 --- /dev/null +++ b/packages/test-snaps/src/features/snaps/cronjobs/components/index.ts @@ -0,0 +1,3 @@ +export * from './CancelBackgroundEvent'; +export * from './GetBackgroundEvents'; +export * from './ScheduleBackgroundEvent'; diff --git a/yarn.lock b/yarn.lock index 14be0e30a8..e6a1dbefbf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5735,6 +5735,7 @@ __metadata: "@types/concat-stream": "npm:^2.0.0" "@types/gunzip-maybe": "npm:^1.4.0" "@types/jest": "npm:^27.5.1" + "@types/luxon": "npm:^3" "@types/mocha": "npm:^10.0.1" "@types/node": "npm:18.14.2" "@types/readable-stream": "npm:^4.0.15" @@ -5772,6 +5773,7 @@ __metadata: jest: "npm:^29.0.2" jest-fetch-mock: "npm:^3.0.3" jest-silent-reporter: "npm:^0.6.0" + luxon: "npm:^3.5.0" mkdirp: "npm:^1.0.4" nanoid: "npm:^3.1.31" prettier: "npm:^2.8.8" From 52cb0cff7ed26522b4761dd70766f50a898cc289 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Thu, 12 Dec 2024 15:20:16 -0500 Subject: [PATCH 15/38] rebuild --- packages/examples/packages/browserify-plugin/snap.manifest.json | 2 +- packages/examples/packages/browserify/snap.manifest.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/examples/packages/browserify-plugin/snap.manifest.json b/packages/examples/packages/browserify-plugin/snap.manifest.json index 160ebe7674..dceb593bea 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": "XMEM/j4XBZLY1nz8Ow7kFic+ReGDTkBwZrGmAnj5YJk=", + "shasum": "s12UIOryiChkisq8WSYObhBAMlWjpC6M1OK8q+a0ofY=", "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 cebc08034d..79e7b04165 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": "4Ld6BCuGlD4EQddRn2VHfW+NaDKU4SR54G5IW5bN3D8=", + "shasum": "EArOMEar808H2zLk7HVyOXXPWGYfjQxYZp4RnmXTlW4=", "location": { "npm": { "filePath": "dist/bundle.js", From f49240a674901472eb8f9aaaea49eb8d41711941 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Thu, 12 Dec 2024 15:22:13 -0500 Subject: [PATCH 16/38] update coverage --- packages/snaps-rpc-methods/jest.config.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/snaps-rpc-methods/jest.config.js b/packages/snaps-rpc-methods/jest.config.js index 73bee7457a..7cc7f1ef49 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.05, + branches: 92.8, functions: 97.35, - lines: 97.99, - statements: 97.53, + lines: 97.92, + statements: 97.47, }, }, }); From 598eff67605da1feaa27bb2040118ea6cd09fc12 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Thu, 12 Dec 2024 15:33:48 -0500 Subject: [PATCH 17/38] update coverage again --- packages/snaps-rpc-methods/jest.config.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/snaps-rpc-methods/jest.config.js b/packages/snaps-rpc-methods/jest.config.js index 7cc7f1ef49..8cd5f96866 100644 --- a/packages/snaps-rpc-methods/jest.config.js +++ b/packages/snaps-rpc-methods/jest.config.js @@ -12,8 +12,8 @@ module.exports = deepmerge(baseConfig, { global: { branches: 92.8, functions: 97.35, - lines: 97.92, - statements: 97.47, + lines: 97.35, + statements: 96.93, }, }, }); From e6cb6aa6934642babd5a9aa5790b43ebe48d96cb Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Thu, 12 Dec 2024 15:42:48 -0500 Subject: [PATCH 18/38] update snaps controllers coverage --- packages/snaps-controllers/coverage.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/snaps-controllers/coverage.json b/packages/snaps-controllers/coverage.json index 1a634a646c..7e25af924a 100644 --- a/packages/snaps-controllers/coverage.json +++ b/packages/snaps-controllers/coverage.json @@ -1,6 +1,6 @@ { "branches": 92.98, - "functions": 95.98, - "lines": 97.97, - "statements": 97.63 + "functions": 96.54, + "lines": 98.02, + "statements": 97.73 } From 0e85a3d0055d6b15cdf5584ac011aaa1f6b129ee Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Thu, 12 Dec 2024 19:17:49 -0500 Subject: [PATCH 19/38] fix logic --- packages/snaps-rpc-methods/jest.config.js | 8 ++++---- .../src/permitted/scheduleBackgroundEvent.ts | 4 +++- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/snaps-rpc-methods/jest.config.js b/packages/snaps-rpc-methods/jest.config.js index 8cd5f96866..c3a6a60b56 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.8, - functions: 97.35, - lines: 97.35, - statements: 96.93, + branches: 92.83, + functions: 97.36, + lines: 97.92, + statements: 97.47, }, }, }); diff --git a/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.ts b/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.ts index 0b0a8183c6..95d1b67218 100644 --- a/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.ts +++ b/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.ts @@ -58,7 +58,9 @@ const ScheduleBackgroundEventsParametersStruct = object({ date: refine(string(), 'date', (val) => { const date = DateTime.fromISO(val); if (date.isValid) { - if (!val.endsWith('Z') || date.offset === 0) { + // luxon doesn't have a reliable way to check if timezone info was not provided + const count = val.split('').filter((char) => char === '-').length; + if (count !== 3 && !val.includes('+') && !val.endsWith('Z')) { return 'ISO8601 string must have timezone information'; } return true; From d9f44ce827c45e7507558a8a316138e80c5974b9 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Thu, 12 Dec 2024 20:28:20 -0500 Subject: [PATCH 20/38] fix test --- .../src/permitted/scheduleBackgroundEvent.test.ts | 4 ++-- .../src/permitted/scheduleBackgroundEvent.ts | 7 ++++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.test.ts b/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.test.ts index 19af3a2f8a..93f41503ec 100644 --- a/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.test.ts +++ b/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.test.ts @@ -104,7 +104,7 @@ describe('snap_scheduleBackgroundEvent', () => { id: 1, method: 'snap_scheduleBackgroundEvent', params: { - date: '2022-01-01T01:00:35Z', + date: '2022-01-01T01:00:35+02:00', request: { method: 'handleExport', params: ['p1'], @@ -113,7 +113,7 @@ describe('snap_scheduleBackgroundEvent', () => { }); expect(scheduleBackgroundEvent).toHaveBeenCalledWith({ - date: '2021-12-31T20:00:00.000-05:00', + date: '2021-12-31T18:00-05:00', request: { method: 'handleExport', params: ['p1'], diff --git a/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.ts b/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.ts index 95d1b67218..505332157d 100644 --- a/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.ts +++ b/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.ts @@ -114,9 +114,10 @@ async function getScheduleBackgroundEventImplementation( const { date, request } = validatedParams; // make sure any second/millisecond precision is removed. - const truncatedDate = DateTime.fromISO(date) - .startOf('minute') - .toISO() as string; + const truncatedDate = DateTime.fromISO(date).startOf('minute').toISO({ + suppressMilliseconds: true, + suppressSeconds: true, + }) as string; const id = scheduleBackgroundEvent({ date: truncatedDate, request }); res.result = id; From c21e216fe603978d592c272d855ce6b8d8384c84 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Thu, 12 Dec 2024 20:56:50 -0500 Subject: [PATCH 21/38] fix tests --- .../src/permitted/scheduleBackgroundEvent.test.ts | 2 +- .../src/permitted/scheduleBackgroundEvent.ts | 12 +++++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.test.ts b/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.test.ts index 93f41503ec..444fb89862 100644 --- a/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.test.ts +++ b/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.test.ts @@ -113,7 +113,7 @@ describe('snap_scheduleBackgroundEvent', () => { }); expect(scheduleBackgroundEvent).toHaveBeenCalledWith({ - date: '2021-12-31T18:00-05:00', + date: '2022-01-01T01:00+02:00', request: { method: 'handleExport', params: ['p1'], diff --git a/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.ts b/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.ts index 505332157d..ab741f10a7 100644 --- a/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.ts +++ b/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.ts @@ -56,7 +56,7 @@ export const scheduleBackgroundEventHandler: PermittedHandlerExport< const ScheduleBackgroundEventsParametersStruct = object({ date: refine(string(), 'date', (val) => { - const date = DateTime.fromISO(val); + const date = DateTime.fromISO(val, { setZone: true }); if (date.isValid) { // luxon doesn't have a reliable way to check if timezone info was not provided const count = val.split('').filter((char) => char === '-').length; @@ -114,10 +114,12 @@ async function getScheduleBackgroundEventImplementation( const { date, request } = validatedParams; // make sure any second/millisecond precision is removed. - const truncatedDate = DateTime.fromISO(date).startOf('minute').toISO({ - suppressMilliseconds: true, - suppressSeconds: true, - }) as string; + const truncatedDate = DateTime.fromISO(date, { setZone: true }) + .startOf('minute') + .toISO({ + suppressMilliseconds: true, + suppressSeconds: true, + }) as string; const id = scheduleBackgroundEvent({ date: truncatedDate, request }); res.result = id; From 757b5a2f20bf7dafd539071c5652b11932fbfaa6 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Thu, 12 Dec 2024 21:07:21 -0500 Subject: [PATCH 22/38] remove unncecessary option --- .../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 ab741f10a7..cb485d0320 100644 --- a/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.ts +++ b/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.ts @@ -56,7 +56,7 @@ export const scheduleBackgroundEventHandler: PermittedHandlerExport< const ScheduleBackgroundEventsParametersStruct = object({ date: refine(string(), 'date', (val) => { - const date = DateTime.fromISO(val, { setZone: true }); + const date = DateTime.fromISO(val); if (date.isValid) { // luxon doesn't have a reliable way to check if timezone info was not provided const count = val.split('').filter((char) => char === '-').length; From 528d3f043f0fddb42b24521ad2815125edb04999 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Fri, 13 Dec 2024 10:45:12 -0500 Subject: [PATCH 23/38] address PR comments --- .../examples/packages/cronjobs/src/index.ts | 2 +- .../src/cronjob/CronjobController.test.ts | 27 ++++ .../src/cronjob/CronjobController.ts | 8 +- packages/snaps-rpc-methods/jest.config.js | 8 +- .../src/endowments/cronjob.test.ts | 123 +++++++++++++++++- .../src/endowments/cronjob.ts | 15 +-- .../permitted/cancelBackgroundEvent.test.ts | 6 +- .../src/permitted/cancelBackgroundEvent.ts | 10 +- .../src/permitted/getBackgroundEvents.test.ts | 6 +- .../src/permitted/getBackgroundEvents.ts | 14 +- .../permitted/scheduleBackgroundEvent.test.ts | 12 +- .../src/permitted/scheduleBackgroundEvent.ts | 14 +- .../types/methods/get-background-events.ts | 2 +- .../methods/schedule-background-event.ts | 2 +- 14 files changed, 193 insertions(+), 56 deletions(-) diff --git a/packages/examples/packages/cronjobs/src/index.ts b/packages/examples/packages/cronjobs/src/index.ts index 35cc29ccfa..1523d9052b 100644 --- a/packages/examples/packages/cronjobs/src/index.ts +++ b/packages/examples/packages/cronjobs/src/index.ts @@ -48,7 +48,7 @@ export const onCronjob: OnCronjobHandler = async ({ request }) => { /** * Handle incoming JSON-RPC requests from the dapp, sent through the - * `wallet_invokeSnap` method. This handler handles two methods: + * `wallet_invokeSnap` method. This handler handles three methods: * * - `scheduleNotification`: Schedule a notification in the future. * - `cancelNotification`: Cancel a notification. diff --git a/packages/snaps-controllers/src/cronjob/CronjobController.test.ts b/packages/snaps-controllers/src/cronjob/CronjobController.test.ts index 83925bed4f..78a5655386 100644 --- a/packages/snaps-controllers/src/cronjob/CronjobController.test.ts +++ b/packages/snaps-controllers/src/cronjob/CronjobController.test.ts @@ -288,6 +288,33 @@ describe('CronjobController', () => { cronjobController.destroy(); }); + it('fails to schedule a background event if the date is in the past', () => { + const rootMessenger = getRootCronjobControllerMessenger(); + const controllerMessenger = + getRestrictedCronjobControllerMessenger(rootMessenger); + + const cronjobController = new CronjobController({ + messenger: controllerMessenger, + }); + + const backgroundEvent = { + snapId: MOCK_SNAP_ID, + date: '2021-01-01T01:00Z', + request: { + method: 'handleEvent', + params: ['p1'], + }, + }; + + expect(() => + cronjobController.scheduleBackgroundEvent(backgroundEvent), + ).toThrow('Cannot schedule an event in the past.'); + + expect(cronjobController.state.events).toStrictEqual({}); + + cronjobController.destroy(); + }); + it('cancels a background event', () => { const rootMessenger = getRootCronjobControllerMessenger(); const controllerMessenger = diff --git a/packages/snaps-controllers/src/cronjob/CronjobController.ts b/packages/snaps-controllers/src/cronjob/CronjobController.ts index b8d88f1617..f5f18f5b15 100644 --- a/packages/snaps-controllers/src/cronjob/CronjobController.ts +++ b/packages/snaps-controllers/src/cronjob/CronjobController.ts @@ -340,10 +340,10 @@ export class CronjobController extends BaseController< scheduleBackgroundEvent( backgroundEventWithoutId: Omit, ) { - // removing minute precision and converting to UTC. + // removing milliseond precision and converting to UTC. const scheduledAt = DateTime.fromJSDate(new Date()) .toUTC() - .toFormat("yyyy-MM-dd'T'HH:mm'Z'"); + .toFormat("yyyy-MM-dd'T'HH:mm:ss'Z'"); const event = { ...backgroundEventWithoutId, id: nanoid(), @@ -394,6 +394,10 @@ export class CronjobController extends BaseController< const now = new Date(); const ms = date.getTime() - now.getTime(); + if (ms < 0) { + throw new Error('Cannot schedule an event in the past.'); + } + const timer = new Timer(ms); timer.start(() => { this.#messenger diff --git a/packages/snaps-rpc-methods/jest.config.js b/packages/snaps-rpc-methods/jest.config.js index c3a6a60b56..bf2e08dfb1 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.83, - functions: 97.36, - lines: 97.92, - statements: 97.47, + branches: 93.23, + functions: 97.89, + lines: 98.48, + statements: 98.01, }, }, }); diff --git a/packages/snaps-rpc-methods/src/endowments/cronjob.test.ts b/packages/snaps-rpc-methods/src/endowments/cronjob.test.ts index 35c5d91113..c377cbe5cb 100644 --- a/packages/snaps-rpc-methods/src/endowments/cronjob.test.ts +++ b/packages/snaps-rpc-methods/src/endowments/cronjob.test.ts @@ -1,4 +1,7 @@ -import type { Caveat } from '@metamask/permission-controller'; +import type { + Caveat, + PermissionConstraint, +} from '@metamask/permission-controller'; import { PermissionType, SubjectType } from '@metamask/permission-controller'; import { SnapCaveatType } from '@metamask/snaps-utils'; @@ -7,6 +10,7 @@ import { cronjobEndowmentBuilder, validateCronjobCaveat, cronjobCaveatSpecifications, + getCronjobCaveatJobs, } from './cronjob'; import { SnapEndowments } from './enum'; @@ -62,6 +66,123 @@ describe('endowment:cronjob', () => { }); }); +describe('getCronjobCaveatJobs', () => { + it('returns the jobs from a cronjob caveat', () => { + const permission: PermissionConstraint = { + date: 0, + parentCapability: 'foo', + invoker: 'bar', + id: 'baz', + caveats: [ + { + type: SnapCaveatType.SnapCronjob, + value: { + jobs: [ + { + expression: '* * * * *', + request: { + method: 'exampleMethodOne', + params: ['p1'], + }, + }, + ], + }, + }, + ], + }; + + expect(getCronjobCaveatJobs(permission)).toStrictEqual([ + { + expression: '* * * * *', + request: { + method: 'exampleMethodOne', + params: ['p1'], + }, + }, + ]); + }); + + it('returns null if there are no caveats', () => { + const permission: PermissionConstraint = { + date: 0, + parentCapability: 'foo', + invoker: 'bar', + id: 'baz', + caveats: null, + }; + + expect(getCronjobCaveatJobs(permission)).toBeNull(); + }); + + it('will throw if there is more than one caveat', () => { + const permission: PermissionConstraint = { + date: 0, + parentCapability: 'foo', + invoker: 'bar', + id: 'baz', + caveats: [ + { + type: SnapCaveatType.SnapCronjob, + value: { + jobs: [ + { + expression: '* * * * *', + request: { + method: 'exampleMethodOne', + params: ['p1'], + }, + }, + ], + }, + }, + { + type: SnapCaveatType.SnapCronjob, + value: { + jobs: [ + { + expression: '* * * * *', + request: { + method: 'exampleMethodOne', + params: ['p1'], + }, + }, + ], + }, + }, + ], + }; + + expect(() => getCronjobCaveatJobs(permission)).toThrow('Assertion failed.'); + }); + + it('will throw if the caveat type is wrong', () => { + const permission: PermissionConstraint = { + date: 0, + parentCapability: 'foo', + invoker: 'bar', + id: 'baz', + caveats: [ + { + type: SnapCaveatType.ChainIds, + value: { + jobs: [ + { + expression: '* * * * *', + request: { + method: 'exampleMethodOne', + params: ['p1'], + }, + }, + ], + }, + }, + ], + }; + + expect(() => getCronjobCaveatJobs(permission)).toThrow('Assertion failed.'); + }); +}); + describe('validateCronjobCaveat', () => { it('should not throw an error when provided specification is valid', () => { const caveat: Caveat = { diff --git a/packages/snaps-rpc-methods/src/endowments/cronjob.ts b/packages/snaps-rpc-methods/src/endowments/cronjob.ts index 527a95e96a..9ebb84b5fd 100644 --- a/packages/snaps-rpc-methods/src/endowments/cronjob.ts +++ b/packages/snaps-rpc-methods/src/endowments/cronjob.ts @@ -62,7 +62,10 @@ export const cronjobEndowmentBuilder = Object.freeze({ */ export function getCronjobCaveatMapper( value: Json, -): Pick { +): Pick | Record { + if (Object.keys(value as Record).length === 0) { + return {}; + } return { caveats: [ { @@ -96,7 +99,7 @@ export function getCronjobCaveatJobs( const caveat = permission.caveats[0] as Caveat; - return (caveat.value?.jobs as CronjobSpecification[]) ?? []; + return (caveat.value?.jobs as CronjobSpecification[]) ?? null; } /** @@ -116,17 +119,13 @@ export function validateCronjobCaveat(caveat: Caveat) { const { value } = caveat; - if (!isPlainObject(value)) { + if (!hasProperty(value, 'jobs') || !isPlainObject(value)) { throw rpcErrors.invalidParams({ message: 'Expected a plain object.', }); } - const valueKeys = Object.keys(value); - - // If it's an empty object, ok to skip validation as this indicates - // intention to use the background event rpc methods - if (valueKeys.length !== 0 && !isCronjobSpecificationArray(value.jobs)) { + if (!isCronjobSpecificationArray(value.jobs)) { throw rpcErrors.invalidParams({ message: 'Expected a valid cronjob specification array.', }); diff --git a/packages/snaps-rpc-methods/src/permitted/cancelBackgroundEvent.test.ts b/packages/snaps-rpc-methods/src/permitted/cancelBackgroundEvent.test.ts index 6c51087b77..12d323a188 100644 --- a/packages/snaps-rpc-methods/src/permitted/cancelBackgroundEvent.test.ts +++ b/packages/snaps-rpc-methods/src/permitted/cancelBackgroundEvent.test.ts @@ -6,7 +6,6 @@ import type { import { MOCK_SNAP_ID } from '@metamask/snaps-utils/test-utils'; import type { JsonRpcRequest, PendingJsonRpcResponse } from '@metamask/utils'; -import { SnapEndowments } from '../endowments'; import { cancelBackgroundEventHandler } from './cancelBackgroundEvent'; describe('snap_cancelBackgroundEvent', () => { @@ -142,8 +141,9 @@ describe('snap_cancelBackgroundEvent', () => { expect(response).toStrictEqual({ error: { - code: -32600, - message: `The snap "${MOCK_SNAP_ID}" does not have the "${SnapEndowments.Cronjob}" permission.`, + code: 4100, + message: + 'The requested account and/or method has not been authorized by the user.', stack: expect.any(String), }, id: 1, diff --git a/packages/snaps-rpc-methods/src/permitted/cancelBackgroundEvent.ts b/packages/snaps-rpc-methods/src/permitted/cancelBackgroundEvent.ts index 1f02d4daff..2a83b3762a 100644 --- a/packages/snaps-rpc-methods/src/permitted/cancelBackgroundEvent.ts +++ b/packages/snaps-rpc-methods/src/permitted/cancelBackgroundEvent.ts @@ -1,6 +1,6 @@ import type { JsonRpcEngineEndCallback } from '@metamask/json-rpc-engine'; import type { PermittedHandlerExport } from '@metamask/permission-controller'; -import { rpcErrors } from '@metamask/rpc-errors'; +import { providerErrors, rpcErrors } from '@metamask/rpc-errors'; import type { JsonRpcRequest, CancelBackgroundEventParams, @@ -64,14 +64,10 @@ async function getCancelBackgroundEventImplementation( end: JsonRpcEngineEndCallback, { cancelBackgroundEvent, hasPermission }: CancelBackgroundEventMethodHooks, ): Promise { - const { params, origin } = req as JsonRpcRequest & { origin: string }; + const { params } = req; if (!hasPermission(SnapEndowments.Cronjob)) { - return end( - rpcErrors.invalidRequest({ - message: `The snap "${origin}" does not have the "${SnapEndowments.Cronjob}" permission.`, - }), - ); + return end(providerErrors.unauthorized()); } try { diff --git a/packages/snaps-rpc-methods/src/permitted/getBackgroundEvents.test.ts b/packages/snaps-rpc-methods/src/permitted/getBackgroundEvents.test.ts index 318d8acea6..b6ff74e26b 100644 --- a/packages/snaps-rpc-methods/src/permitted/getBackgroundEvents.test.ts +++ b/packages/snaps-rpc-methods/src/permitted/getBackgroundEvents.test.ts @@ -6,7 +6,6 @@ import type { import { MOCK_SNAP_ID } from '@metamask/snaps-utils/test-utils'; import type { JsonRpcRequest, PendingJsonRpcResponse } from '@metamask/utils'; -import { SnapEndowments } from '../endowments'; import { getBackgroundEventsHandler } from './getBackgroundEvents'; describe('snap_getBackgroundEvents', () => { @@ -207,8 +206,9 @@ describe('snap_getBackgroundEvents', () => { expect(response).toStrictEqual({ error: { - code: -32600, - message: `The snap "${MOCK_SNAP_ID}" does not have the "${SnapEndowments.Cronjob}" permission.`, + code: 4100, + message: + 'The requested account and/or method has not been authorized by the user.', stack: expect.any(String), }, id: 1, diff --git a/packages/snaps-rpc-methods/src/permitted/getBackgroundEvents.ts b/packages/snaps-rpc-methods/src/permitted/getBackgroundEvents.ts index 4e0aeb55be..25b956c528 100644 --- a/packages/snaps-rpc-methods/src/permitted/getBackgroundEvents.ts +++ b/packages/snaps-rpc-methods/src/permitted/getBackgroundEvents.ts @@ -1,6 +1,6 @@ import type { JsonRpcEngineEndCallback } from '@metamask/json-rpc-engine'; import type { PermittedHandlerExport } from '@metamask/permission-controller'; -import { rpcErrors } from '@metamask/rpc-errors'; +import { providerErrors } from '@metamask/rpc-errors'; import type { BackgroundEvent, GetBackgroundEventsParams, @@ -37,7 +37,7 @@ export const getBackgroundEventsHandler: PermittedHandlerExport< /** * The `snap_getBackgroundEvents` method implementation. * - * @param req - The JSON-RPC request object. + * @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. @@ -48,20 +48,14 @@ export const getBackgroundEventsHandler: PermittedHandlerExport< * @returns An array of background events. */ async function getGetBackgroundEventsImplementation( - req: JsonRpcRequest, + _req: JsonRpcRequest, res: PendingJsonRpcResponse, _next: unknown, end: JsonRpcEngineEndCallback, { getBackgroundEvents, hasPermission }: GetBackgroundEventsMethodHooks, ): Promise { - const { 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.`, - }), - ); + return end(providerErrors.unauthorized()); } try { const events = getBackgroundEvents(); diff --git a/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.test.ts b/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.test.ts index 444fb89862..ca51212137 100644 --- a/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.test.ts +++ b/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.test.ts @@ -6,7 +6,6 @@ import type { 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', () => { @@ -73,7 +72,7 @@ describe('snap_scheduleBackgroundEvent', () => { expect(response).toStrictEqual({ jsonrpc: '2.0', id: 1, result: 'foo' }); }); - it('schedules a background event with minute precision', async () => { + it('schedules a background event with second precision', async () => { const { implementation } = scheduleBackgroundEventHandler; const scheduleBackgroundEvent = jest.fn(); @@ -104,7 +103,7 @@ describe('snap_scheduleBackgroundEvent', () => { id: 1, method: 'snap_scheduleBackgroundEvent', params: { - date: '2022-01-01T01:00:35+02:00', + date: '2022-01-01T01:00:35.786+02:00', request: { method: 'handleExport', params: ['p1'], @@ -113,7 +112,7 @@ describe('snap_scheduleBackgroundEvent', () => { }); expect(scheduleBackgroundEvent).toHaveBeenCalledWith({ - date: '2022-01-01T01:00+02:00', + date: '2022-01-01T01:00:35+02:00', request: { method: 'handleExport', params: ['p1'], @@ -162,8 +161,9 @@ describe('snap_scheduleBackgroundEvent', () => { expect(response).toStrictEqual({ error: { - code: -32600, - message: `The snap "${MOCK_SNAP_ID}" does not have the "${SnapEndowments.Cronjob}" permission.`, + code: 4100, + message: + 'The requested account and/or method has not been authorized by the user.', stack: expect.any(String), }, id: 1, diff --git a/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.ts b/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.ts index cb485d0320..27710443cb 100644 --- a/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.ts +++ b/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.ts @@ -1,6 +1,6 @@ import type { JsonRpcEngineEndCallback } from '@metamask/json-rpc-engine'; import type { PermittedHandlerExport } from '@metamask/permission-controller'; -import { rpcErrors } from '@metamask/rpc-errors'; +import { providerErrors, rpcErrors } from '@metamask/rpc-errors'; import type { JsonRpcRequest, ScheduleBackgroundEventParams, @@ -98,14 +98,10 @@ async function getScheduleBackgroundEventImplementation( hasPermission, }: ScheduleBackgroundEventMethodHooks, ): Promise { - const { params, origin } = req as JsonRpcRequest & { origin: string }; + const { params } = req; if (!hasPermission(SnapEndowments.Cronjob)) { - return end( - rpcErrors.invalidRequest({ - message: `The snap "${origin}" does not have the "${SnapEndowments.Cronjob}" permission.`, - }), - ); + return end(providerErrors.unauthorized()); } try { @@ -115,10 +111,10 @@ async function getScheduleBackgroundEventImplementation( // make sure any second/millisecond precision is removed. const truncatedDate = DateTime.fromISO(date, { setZone: true }) - .startOf('minute') + .startOf('second') .toISO({ suppressMilliseconds: true, - suppressSeconds: true, + suppressSeconds: false, }) as string; const id = scheduleBackgroundEvent({ date: truncatedDate, request }); 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 eb281d6232..9b92da56cf 100644 --- a/packages/snaps-sdk/src/types/methods/get-background-events.ts +++ b/packages/snaps-sdk/src/types/methods/get-background-events.ts @@ -3,7 +3,7 @@ import type { Json } from '@metamask/utils'; import type { SnapId } from '../snap'; /** - * Backgound event type + * Background event type */ export type BackgroundEvent = { id: string; 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 40c76e2abe..caa58e3390 100644 --- a/packages/snaps-sdk/src/types/methods/schedule-background-event.ts +++ b/packages/snaps-sdk/src/types/methods/schedule-background-event.ts @@ -12,6 +12,6 @@ export type ScheduleBackgroundEventParams = { }; /** - * The result returned by the `snap_scheduleBackgroundEvent` method. + * The result returned by the `snap_scheduleBackgroundEvent` method, which is the ID of the scheduled event. */ export type ScheduleBackgroundEventResult = string; From bfbaeb76a3f2492fa27b91d855bf8abfae34e44b Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Fri, 13 Dec 2024 10:50:19 -0500 Subject: [PATCH 24/38] fix type --- packages/snaps-rpc-methods/src/endowments/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/snaps-rpc-methods/src/endowments/index.ts b/packages/snaps-rpc-methods/src/endowments/index.ts index faa0c8fe06..5e851671ce 100644 --- a/packages/snaps-rpc-methods/src/endowments/index.ts +++ b/packages/snaps-rpc-methods/src/endowments/index.ts @@ -2,6 +2,7 @@ import type { PermissionConstraint } from '@metamask/permission-controller'; import { HandlerType } from '@metamask/snaps-utils'; import type { Json } from '@metamask/utils'; +import type { CaveatMapperFunction } from './caveats'; import { createMaxRequestTimeMapper, getMaxRequestTimeCaveatMapper, @@ -75,7 +76,7 @@ export const endowmentCaveatMappers: Record< (value: Json) => Pick > = { [cronjobEndowmentBuilder.targetName]: createMaxRequestTimeMapper( - getCronjobCaveatMapper, + getCronjobCaveatMapper as CaveatMapperFunction, ), [transactionInsightEndowmentBuilder.targetName]: createMaxRequestTimeMapper( getTransactionInsightCaveatMapper, From 8bccf3a168ab13df10b38271d557890c78d80b03 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Fri, 13 Dec 2024 11:10:20 -0500 Subject: [PATCH 25/38] update snap controllers coverage --- packages/snaps-controllers/coverage.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/snaps-controllers/coverage.json b/packages/snaps-controllers/coverage.json index 7e25af924a..28b3b2839a 100644 --- a/packages/snaps-controllers/coverage.json +++ b/packages/snaps-controllers/coverage.json @@ -1,6 +1,6 @@ { - "branches": 92.98, + "branches": 93, "functions": 96.54, "lines": 98.02, - "statements": 97.73 + "statements": 97.74 } From 7a54460ef9826c9141f7b650f55b12f34a6be2b0 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Mon, 16 Dec 2024 09:44:05 -0500 Subject: [PATCH 26/38] address PR comments --- .../examples/packages/cronjobs/snap.manifest.json | 3 ++- .../snaps-rpc-methods/src/endowments/cronjob.ts | 13 +++++++++---- packages/snaps-rpc-methods/src/endowments/index.ts | 3 +-- .../src/permitted/scheduleBackgroundEvent.ts | 3 +-- 4 files changed, 13 insertions(+), 9 deletions(-) diff --git a/packages/examples/packages/cronjobs/snap.manifest.json b/packages/examples/packages/cronjobs/snap.manifest.json index d95477003d..23ce053fb8 100644 --- a/packages/examples/packages/cronjobs/snap.manifest.json +++ b/packages/examples/packages/cronjobs/snap.manifest.json @@ -31,7 +31,8 @@ } ] }, - "snap_dialog": {} + "snap_dialog": {}, + "snap_notify": {} }, "platformVersion": "6.13.0", "manifestVersion": "0.1" diff --git a/packages/snaps-rpc-methods/src/endowments/cronjob.ts b/packages/snaps-rpc-methods/src/endowments/cronjob.ts index 9ebb84b5fd..649cd1d873 100644 --- a/packages/snaps-rpc-methods/src/endowments/cronjob.ts +++ b/packages/snaps-rpc-methods/src/endowments/cronjob.ts @@ -14,8 +14,9 @@ import { isCronjobSpecificationArray, } from '@metamask/snaps-utils'; import type { Json, NonEmptyArray } from '@metamask/utils'; -import { assert, hasProperty, isPlainObject } from '@metamask/utils'; +import { assert, hasProperty, isObject, isPlainObject } from '@metamask/utils'; +import { createGenericPermissionValidator } from './caveats'; import { SnapEndowments } from './enum'; const permissionName = SnapEndowments.Cronjob; @@ -44,6 +45,10 @@ const specificationBuilder: PermissionSpecificationBuilder< allowedCaveats: [SnapCaveatType.SnapCronjob], endowmentGetter: (_getterOptions?: EndowmentGetterParams) => null, subjectTypes: [SubjectType.Snap], + validator: createGenericPermissionValidator([ + { type: SnapCaveatType.SnapCronjob, optional: true }, + { type: SnapCaveatType.MaxRequestTime, optional: true }, + ]), }; }; @@ -62,9 +67,9 @@ export const cronjobEndowmentBuilder = Object.freeze({ */ export function getCronjobCaveatMapper( value: Json, -): Pick | Record { - if (Object.keys(value as Record).length === 0) { - return {}; +): Pick { + if (!value || !isObject(value) || Object.keys(value).length === 0) { + return { caveats: null }; } return { caveats: [ diff --git a/packages/snaps-rpc-methods/src/endowments/index.ts b/packages/snaps-rpc-methods/src/endowments/index.ts index 5e851671ce..faa0c8fe06 100644 --- a/packages/snaps-rpc-methods/src/endowments/index.ts +++ b/packages/snaps-rpc-methods/src/endowments/index.ts @@ -2,7 +2,6 @@ import type { PermissionConstraint } from '@metamask/permission-controller'; import { HandlerType } from '@metamask/snaps-utils'; import type { Json } from '@metamask/utils'; -import type { CaveatMapperFunction } from './caveats'; import { createMaxRequestTimeMapper, getMaxRequestTimeCaveatMapper, @@ -76,7 +75,7 @@ export const endowmentCaveatMappers: Record< (value: Json) => Pick > = { [cronjobEndowmentBuilder.targetName]: createMaxRequestTimeMapper( - getCronjobCaveatMapper as CaveatMapperFunction, + getCronjobCaveatMapper, ), [transactionInsightEndowmentBuilder.targetName]: createMaxRequestTimeMapper( getTransactionInsightCaveatMapper, diff --git a/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.ts b/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.ts index 27710443cb..e38c79420d 100644 --- a/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.ts +++ b/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.ts @@ -109,12 +109,11 @@ async function getScheduleBackgroundEventImplementation( const { date, request } = validatedParams; - // make sure any second/millisecond precision is removed. + // Make sure any millisecond precision is removed. const truncatedDate = DateTime.fromISO(date, { setZone: true }) .startOf('second') .toISO({ suppressMilliseconds: true, - suppressSeconds: false, }) as string; const id = scheduleBackgroundEvent({ date: truncatedDate, request }); From 4702f8d9dc42fde7a658810a2bc09e7442d4df73 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Mon, 16 Dec 2024 11:20:45 -0500 Subject: [PATCH 27/38] update validation logic --- .../src/permitted/scheduleBackgroundEvent.ts | 4 ++-- packages/snaps-simulation/src/methods/specifications.test.ts | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.ts b/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.ts index e38c79420d..61ada80677 100644 --- a/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.ts +++ b/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.ts @@ -54,13 +54,13 @@ export const scheduleBackgroundEventHandler: PermittedHandlerExport< hookNames, }; +const offsetRegex = /Z|([+-]\d{2}:?\d{2})$/u; const ScheduleBackgroundEventsParametersStruct = object({ date: refine(string(), 'date', (val) => { const date = DateTime.fromISO(val); if (date.isValid) { // luxon doesn't have a reliable way to check if timezone info was not provided - const count = val.split('').filter((char) => char === '-').length; - if (count !== 3 && !val.includes('+') && !val.endsWith('Z')) { + if (!offsetRegex.test(val)) { return 'ISO8601 string must have timezone information'; } return true; diff --git a/packages/snaps-simulation/src/methods/specifications.test.ts b/packages/snaps-simulation/src/methods/specifications.test.ts index 2c556403e4..65d841baf9 100644 --- a/packages/snaps-simulation/src/methods/specifications.test.ts +++ b/packages/snaps-simulation/src/methods/specifications.test.ts @@ -59,6 +59,7 @@ describe('getPermissionSpecifications', () => { "snap", ], "targetName": "endowment:cronjob", + "validator": [Function], }, "endowment:ethereum-provider": { "allowedCaveats": null, From 600902e84fad5d8b8d504ff113a7dd1ff57e69e2 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Mon, 16 Dec 2024 11:39:55 -0500 Subject: [PATCH 28/38] rebuild and update coverage --- .../examples/packages/browserify-plugin/snap.manifest.json | 2 +- packages/examples/packages/browserify/snap.manifest.json | 2 +- packages/examples/packages/cronjobs/snap.manifest.json | 2 +- packages/snaps-controllers/coverage.json | 4 ++-- packages/snaps-rpc-methods/jest.config.js | 4 ++-- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/examples/packages/browserify-plugin/snap.manifest.json b/packages/examples/packages/browserify-plugin/snap.manifest.json index dceb593bea..6c1ff4c228 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": "s12UIOryiChkisq8WSYObhBAMlWjpC6M1OK8q+a0ofY=", + "shasum": "mcFr6jf+PprMtV9vEagzgQqcMaowV2AaAK0Veu2ncjc=", "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 79e7b04165..bab87a2b43 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": "EArOMEar808H2zLk7HVyOXXPWGYfjQxYZp4RnmXTlW4=", + "shasum": "Qz6azrr61D41uqQIUlPX1Q/FcBmy0VA4aS2LeXn3Iko=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/examples/packages/cronjobs/snap.manifest.json b/packages/examples/packages/cronjobs/snap.manifest.json index 23ce053fb8..3da2d21f7f 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": "+Y2G5jljVTKefA0vWgTQ+k4QLaC4uyLI6aMunBPFbOg=", + "shasum": "mav+LCVs5mW6r7O3TLN+WmNE8aSwGN2fWQ10TPl5Lo0=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/snaps-controllers/coverage.json b/packages/snaps-controllers/coverage.json index 28b3b2839a..31698b1bc4 100644 --- a/packages/snaps-controllers/coverage.json +++ b/packages/snaps-controllers/coverage.json @@ -1,6 +1,6 @@ { - "branches": 93, + "branches": 93.06, "functions": 96.54, - "lines": 98.02, + "lines": 98.03, "statements": 97.74 } diff --git a/packages/snaps-rpc-methods/jest.config.js b/packages/snaps-rpc-methods/jest.config.js index e82dc6d2c9..a6c49fff5c 100644 --- a/packages/snaps-rpc-methods/jest.config.js +++ b/packages/snaps-rpc-methods/jest.config.js @@ -10,8 +10,8 @@ module.exports = deepmerge(baseConfig, { ], coverageThreshold: { global: { - branches: 93.3, - functions: 97.91, + branches: 93.23, + functions: 97.9, lines: 98.5, statements: 98.03, }, From d33eca2c894a9e8be5fb006572027316abe12fe8 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Mon, 16 Dec 2024 18:40:30 -0500 Subject: [PATCH 29/38] increase coverage --- packages/snaps-rpc-methods/jest.config.js | 6 +++--- .../snaps-rpc-methods/src/endowments/cronjob.test.ts | 10 ++++++++++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/snaps-rpc-methods/jest.config.js b/packages/snaps-rpc-methods/jest.config.js index a6c49fff5c..e8bc740d12 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.23, + branches: 93.6, functions: 97.9, - lines: 98.5, - statements: 98.03, + lines: 98.59, + statements: 98.12, }, }, }); diff --git a/packages/snaps-rpc-methods/src/endowments/cronjob.test.ts b/packages/snaps-rpc-methods/src/endowments/cronjob.test.ts index 7ef24d0bc5..6916e49634 100644 --- a/packages/snaps-rpc-methods/src/endowments/cronjob.test.ts +++ b/packages/snaps-rpc-methods/src/endowments/cronjob.test.ts @@ -4,6 +4,7 @@ import type { } from '@metamask/permission-controller'; import { PermissionType, SubjectType } from '@metamask/permission-controller'; import { SnapCaveatType } from '@metamask/snaps-utils'; +import type { Json } from '@metamask/utils'; import { getCronjobCaveatMapper, @@ -64,6 +65,15 @@ describe('endowment:cronjob', () => { ], }); }); + + it.each([undefined, 2, {}])( + 'returns a null caveats value for invalid values', + (val) => { + expect(getCronjobCaveatMapper(val as Json)).toStrictEqual({ + caveats: null, + }); + }, + ); }); }); From feb12e98d9b79513bb60c5d045b34697122eefbe Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Tue, 17 Dec 2024 12:18:23 -0500 Subject: [PATCH 30/38] address PR comments --- .../examples/packages/cronjobs/src/index.ts | 16 +++- .../examples/packages/cronjobs/src/types.ts | 7 ++ .../src/cronjob/CronjobController.test.ts | 2 +- .../src/cronjob/CronjobController.ts | 75 +++++++------------ .../src/endowments/cronjob.test.ts | 4 +- .../src/endowments/cronjob.ts | 1 + .../permitted/cancelBackgroundEvent.test.ts | 3 +- .../src/permitted/getBackgroundEvents.test.ts | 1 + .../permitted/scheduleBackgroundEvent.test.ts | 4 +- .../src/permitted/scheduleBackgroundEvent.ts | 6 +- .../types/methods/get-background-events.ts | 6 ++ 11 files changed, 65 insertions(+), 60 deletions(-) create mode 100644 packages/examples/packages/cronjobs/src/types.ts diff --git a/packages/examples/packages/cronjobs/src/index.ts b/packages/examples/packages/cronjobs/src/index.ts index 1523d9052b..e22d989580 100644 --- a/packages/examples/packages/cronjobs/src/index.ts +++ b/packages/examples/packages/cronjobs/src/index.ts @@ -4,14 +4,22 @@ import type { } from '@metamask/snaps-sdk'; import { panel, text, heading, MethodNotFoundError } from '@metamask/snaps-sdk'; +import type { + CancelNotificationParams, + ScheduleNotificationParams, +} from './types'; + /** - * Handle cronjob execution requests from MetaMask. This handler handles one - * method: + * Handle cronjob execution requests from MetaMask. This handler handles two + * methods: * * - `execute`: The JSON-RPC method that is called by MetaMask when the cronjob * is triggered. This method is specified in the snap manifest under the * `endowment:cronjob` permission. If you want to support more methods (e.g., * with different times), you can add them to the manifest there. + * - `fireNotification`: The JSON-RPC method that is called by MetaMask when the + * background event is triggered. This method call is scheduled by the `scheduleNotification` + * method in the `onRpcRequest` handler. * * @param params - The request parameters. * @param params.request - The JSON-RPC request object. @@ -66,7 +74,7 @@ export const onRpcRequest: OnRpcRequestHandler = async ({ request }) => { return snap.request({ method: 'snap_scheduleBackgroundEvent', params: { - date: (request.params as Record).date, + date: (request.params as ScheduleNotificationParams).date, request: { method: 'fireNotification', }, @@ -76,7 +84,7 @@ export const onRpcRequest: OnRpcRequestHandler = async ({ request }) => { return snap.request({ method: 'snap_cancelBackgroundEvent', params: { - id: (request.params as Record).id, + id: (request.params as CancelNotificationParams).id, }, }); case 'getBackgroundEvents': diff --git a/packages/examples/packages/cronjobs/src/types.ts b/packages/examples/packages/cronjobs/src/types.ts new file mode 100644 index 0000000000..86e2f3b41a --- /dev/null +++ b/packages/examples/packages/cronjobs/src/types.ts @@ -0,0 +1,7 @@ +export type ScheduleNotificationParams = { + date: string; +}; + +export type CancelNotificationParams = { + id: string; +}; diff --git a/packages/snaps-controllers/src/cronjob/CronjobController.test.ts b/packages/snaps-controllers/src/cronjob/CronjobController.test.ts index 78a5655386..4d65257d6a 100644 --- a/packages/snaps-controllers/src/cronjob/CronjobController.test.ts +++ b/packages/snaps-controllers/src/cronjob/CronjobController.test.ts @@ -386,7 +386,7 @@ describe('CronjobController', () => { }); expect(() => cronjobController.cancelBackgroundEvent('foo', id)).toThrow( - 'Only the origin that scheduled this event can cancel it', + 'Only the origin that scheduled this event can cancel it.', ); cronjobController.destroy(); diff --git a/packages/snaps-controllers/src/cronjob/CronjobController.ts b/packages/snaps-controllers/src/cronjob/CronjobController.ts index f5f18f5b15..d07117e332 100644 --- a/packages/snaps-controllers/src/cronjob/CronjobController.ts +++ b/packages/snaps-controllers/src/cronjob/CronjobController.ts @@ -18,6 +18,7 @@ import { HandlerType, parseCronExpression, logError, + logWarning, } from '@metamask/snaps-utils'; import { assert, Duration, inMilliseconds } from '@metamask/utils'; import { castDraft } from 'immer'; @@ -110,8 +111,6 @@ export type CronjobControllerState = { events: Record; }; -const subscriptionMap = new WeakMap(); - const controllerName = 'CronjobController'; /** @@ -150,57 +149,38 @@ 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); + this._handleSnapDisabledEvent = this._handleSnapDisabledEvent.bind(this); + this._handleSnapEnabledEvent = this._handleSnapEnabledEvent.bind(this); // 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', - map.get('SnapController:snapInstalled'), + this._handleSnapRegisterEvent, ); this.messagingSystem.subscribe( 'SnapController:snapUninstalled', - map.get('SnapController:snapUninstalled'), + this._handleSnapUnregisterEvent, ); this.messagingSystem.subscribe( 'SnapController:snapEnabled', - map.get('SnapController:snapEnabled'), + this._handleSnapEnabledEvent, ); this.messagingSystem.subscribe( 'SnapController:snapDisabled', - map.get('SnapController:snapDisabled'), + this._handleSnapDisabledEvent, ); this.messagingSystem.subscribe( 'SnapController:snapUpdated', - map.get('SnapController:snapUpdated'), + this._handleEventSnapUpdated, ); /* eslint-enable @typescript-eslint/unbound-method */ @@ -343,7 +323,10 @@ export class CronjobController extends BaseController< // removing milliseond precision and converting to UTC. const scheduledAt = DateTime.fromJSDate(new Date()) .toUTC() - .toFormat("yyyy-MM-dd'T'HH:mm:ss'Z'"); + .startOf('second') + .toISO({ + suppressMilliseconds: true, + }) as string; const event = { ...backgroundEventWithoutId, id: nanoid(), @@ -372,7 +355,7 @@ export class CronjobController extends BaseController< assert( this.state.events[id].snapId === origin, - 'Only the origin that scheduled this event can cancel it', + 'Only the origin that scheduled this event can cancel it.', ); const timer = this.#timers.get(id); @@ -394,7 +377,7 @@ export class CronjobController extends BaseController< const now = new Date(); const ms = date.getTime() - now.getTime(); - if (ms < 0) { + if (ms <= 0) { throw new Error('Cannot schedule an event in the past.'); } @@ -428,7 +411,7 @@ export class CronjobController extends BaseController< * @param snapId - The id of the Snap to fetch background events for. * @returns An array of background events. */ - getBackgroundEvents(snapId: string): BackgroundEvent[] { + getBackgroundEvents(snapId: SnapId): BackgroundEvent[] { return Object.values(this.state.events).filter( (snapEvent) => snapEvent.snapId === snapId, ); @@ -440,7 +423,7 @@ export class CronjobController extends BaseController< * @param snapId - ID of a snap. * @param skipEvents - Whether the unregistration process should skip scheduled background events. */ - unregister(snapId: string, skipEvents = false) { + unregister(snapId: SnapId, skipEvents = false) { const jobs = [...this.#snapIds.entries()].filter( ([_, jobSnapId]) => jobSnapId === snapId, ); @@ -458,6 +441,7 @@ export class CronjobController extends BaseController< } } }); + if (eventIds.length > 0) { this.update((state) => { eventIds.forEach((id) => { @@ -526,15 +510,14 @@ export class CronjobController extends BaseController< const now = new Date(); const then = new Date(date); if (then.getTime() < now.getTime()) { - // removing expired events from state + // Remove expired events from state this.update((state) => { delete state.events[snapEvent.id]; }); - const error = new Error( + logWarning( `Background event with id "${snapEvent.id}" not scheduled as its date has expired.`, ); - logError(error); } else { this.#setUpBackgroundEvent(snapEvent); } @@ -547,32 +530,30 @@ export class CronjobController extends BaseController< destroy() { super.destroy(); - const subscriptions = subscriptionMap.get(this); - /* eslint-disable @typescript-eslint/unbound-method */ this.messagingSystem.unsubscribe( 'SnapController:snapInstalled', - subscriptions.get('SnapController:snapInstalled'), + this._handleSnapRegisterEvent, ); this.messagingSystem.unsubscribe( 'SnapController:snapUninstalled', - subscriptions.get('SnapController:snapUninstalled'), + this._handleSnapUnregisterEvent, ); this.messagingSystem.unsubscribe( 'SnapController:snapEnabled', - subscriptions.get('SnapController:snapEnabled'), + this._handleSnapEnabledEvent, ); this.messagingSystem.unsubscribe( 'SnapController:snapDisabled', - subscriptions.get('SnapController:snapDisabled'), + this._handleSnapDisabledEvent, ); this.messagingSystem.unsubscribe( 'SnapController:snapUpdated', - subscriptions.get('SnapController:snapUpdated'), + this._handleEventSnapUpdated, ); /* eslint-enable @typescript-eslint/unbound-method */ diff --git a/packages/snaps-rpc-methods/src/endowments/cronjob.test.ts b/packages/snaps-rpc-methods/src/endowments/cronjob.test.ts index 6916e49634..bfeb66c7dc 100644 --- a/packages/snaps-rpc-methods/src/endowments/cronjob.test.ts +++ b/packages/snaps-rpc-methods/src/endowments/cronjob.test.ts @@ -125,7 +125,7 @@ describe('getCronjobCaveatJobs', () => { expect(getCronjobCaveatJobs(permission)).toBeNull(); }); - it('will throw if there is more than one caveat', () => { + it('throws if there is more than one caveat', () => { const permission: PermissionConstraint = { date: 0, parentCapability: 'foo', @@ -166,7 +166,7 @@ describe('getCronjobCaveatJobs', () => { expect(() => getCronjobCaveatJobs(permission)).toThrow('Assertion failed.'); }); - it('will throw if the caveat type is wrong', () => { + it('throws if the caveat type is wrong', () => { const permission: PermissionConstraint = { date: 0, parentCapability: 'foo', diff --git a/packages/snaps-rpc-methods/src/endowments/cronjob.ts b/packages/snaps-rpc-methods/src/endowments/cronjob.ts index 649cd1d873..aa4466deab 100644 --- a/packages/snaps-rpc-methods/src/endowments/cronjob.ts +++ b/packages/snaps-rpc-methods/src/endowments/cronjob.ts @@ -71,6 +71,7 @@ export function getCronjobCaveatMapper( if (!value || !isObject(value) || Object.keys(value).length === 0) { return { caveats: null }; } + return { caveats: [ { diff --git a/packages/snaps-rpc-methods/src/permitted/cancelBackgroundEvent.test.ts b/packages/snaps-rpc-methods/src/permitted/cancelBackgroundEvent.test.ts index 12d323a188..bb5e382c7a 100644 --- a/packages/snaps-rpc-methods/src/permitted/cancelBackgroundEvent.test.ts +++ b/packages/snaps-rpc-methods/src/permitted/cancelBackgroundEvent.test.ts @@ -28,7 +28,8 @@ describe('snap_cancelBackgroundEvent', () => { request.origin = origin; next(); }; - it('returns null after calling the `scheduleBackgroundEvent` hook', async () => { + + it('returns null after calling the `cancelBackgroundEvent` hook', async () => { const { implementation } = cancelBackgroundEventHandler; const cancelBackgroundEvent = jest.fn(); diff --git a/packages/snaps-rpc-methods/src/permitted/getBackgroundEvents.test.ts b/packages/snaps-rpc-methods/src/permitted/getBackgroundEvents.test.ts index b6ff74e26b..c78af4cbb4 100644 --- a/packages/snaps-rpc-methods/src/permitted/getBackgroundEvents.test.ts +++ b/packages/snaps-rpc-methods/src/permitted/getBackgroundEvents.test.ts @@ -28,6 +28,7 @@ describe('snap_getBackgroundEvents', () => { request.origin = origin; next(); }; + it('returns an array of background events after calling the `getBackgroundEvents` hook', async () => { const { implementation } = getBackgroundEventsHandler; diff --git a/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.test.ts b/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.test.ts index ca51212137..a121ef8c1d 100644 --- a/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.test.ts +++ b/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.test.ts @@ -214,7 +214,7 @@ describe('snap_scheduleBackgroundEvent', () => { error: { code: -32602, message: - 'Invalid params: At path: date -- ISO8601 string must have timezone information.', + 'Invalid params: At path: date -- ISO 8601 string must have timezone information.', stack: expect.any(String), }, id: 1, @@ -265,7 +265,7 @@ describe('snap_scheduleBackgroundEvent', () => { error: { code: -32602, message: - 'Invalid params: At path: date -- Not a valid ISO8601 string.', + 'Invalid params: At path: date -- Not a valid ISO 8601 string.', stack: expect.any(String), }, id: 1, diff --git a/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.ts b/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.ts index 61ada80677..ff08f0e4f2 100644 --- a/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.ts +++ b/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.ts @@ -59,13 +59,13 @@ const ScheduleBackgroundEventsParametersStruct = object({ date: refine(string(), 'date', (val) => { const date = DateTime.fromISO(val); if (date.isValid) { - // luxon doesn't have a reliable way to check if timezone info was not provided + // Luxon doesn't have a reliable way to check if timezone info was not provided if (!offsetRegex.test(val)) { - return 'ISO8601 string must have timezone information'; + return 'ISO 8601 string must have timezone information'; } return true; } - return 'Not a valid ISO8601 string'; + return 'Not a valid ISO 8601 string'; }), request: CronjobRpcRequestStruct, }); 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 9b92da56cf..84fd6dfcff 100644 --- a/packages/snaps-sdk/src/types/methods/get-background-events.ts +++ b/packages/snaps-sdk/src/types/methods/get-background-events.ts @@ -4,6 +4,12 @@ import type { SnapId } from '../snap'; /** * Background event type + * + * @property id - The unique id representing the event. + * @property scheduledAt - The ISO 8601 time stamp of when the event was scheduled. + * @property snapId - The id of the snap that scheduled the event. + * @property date - The ISO 8601 date of when the event is scheduled for. + * @property request - The request that is supplied to the `onCronjob` handler when the event is fired. */ export type BackgroundEvent = { id: string; From 63c24e988f4143cce4982e190db55383c3989c5b Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Tue, 17 Dec 2024 12:25:52 -0500 Subject: [PATCH 31/38] fix type issue, rebuild --- packages/examples/packages/browserify-plugin/snap.manifest.json | 2 +- packages/examples/packages/browserify/snap.manifest.json | 2 +- packages/examples/packages/cronjobs/snap.manifest.json | 2 +- packages/snaps-controllers/src/cronjob/CronjobController.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/examples/packages/browserify-plugin/snap.manifest.json b/packages/examples/packages/browserify-plugin/snap.manifest.json index ce2226aadd..d202bd3826 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": "mcFr6jf+PprMtV9vEagzgQqcMaowV2AaAK0Veu2ncjc=", + "shasum": "9v14rgSPwHyW1Wxg94LVBTC3pya/0MNBiQiULxcjdAI=", "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 e3ba2c7708..72e3a9edc5 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": "Qz6azrr61D41uqQIUlPX1Q/FcBmy0VA4aS2LeXn3Iko=", + "shasum": "w+jasFs5zm0dc8qZhW0rM+xOWJC4Y8kBsGj89kSEQhM=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/examples/packages/cronjobs/snap.manifest.json b/packages/examples/packages/cronjobs/snap.manifest.json index c275b82eac..6a45524c37 100644 --- a/packages/examples/packages/cronjobs/snap.manifest.json +++ b/packages/examples/packages/cronjobs/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snaps.git" }, "source": { - "shasum": "mav+LCVs5mW6r7O3TLN+WmNE8aSwGN2fWQ10TPl5Lo0=", + "shasum": "6RCKkCSH+tCAKsXIzAjpaZrWjvGDXbkMcuaCslVvwr4=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/snaps-controllers/src/cronjob/CronjobController.ts b/packages/snaps-controllers/src/cronjob/CronjobController.ts index d07117e332..c4620d1d8e 100644 --- a/packages/snaps-controllers/src/cronjob/CronjobController.ts +++ b/packages/snaps-controllers/src/cronjob/CronjobController.ts @@ -129,7 +129,7 @@ export class CronjobController extends BaseController< #timers: Map; // Mapping from jobId to snapId - #snapIds: Map; + #snapIds: Map; constructor({ messenger, state }: CronjobControllerArgs) { super({ From 68214c6e39d381340abc67497f18065583dbe483 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Tue, 17 Dec 2024 12:46:50 -0500 Subject: [PATCH 32/38] update snap controllers coverage --- packages/snaps-controllers/coverage.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/snaps-controllers/coverage.json b/packages/snaps-controllers/coverage.json index 31698b1bc4..357e17f097 100644 --- a/packages/snaps-controllers/coverage.json +++ b/packages/snaps-controllers/coverage.json @@ -1,6 +1,6 @@ { "branches": 93.06, "functions": 96.54, - "lines": 98.03, + "lines": 98.02, "statements": 97.74 } From e68ce95276dcceda5daa35ea535c444782b22048 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Tue, 17 Dec 2024 13:26:01 -0500 Subject: [PATCH 33/38] update allowed actions in controller-utils and document example snap params types --- packages/examples/packages/cronjobs/src/types.ts | 10 ++++++++++ .../snaps-controllers/src/test-utils/controller.ts | 1 + 2 files changed, 11 insertions(+) diff --git a/packages/examples/packages/cronjobs/src/types.ts b/packages/examples/packages/cronjobs/src/types.ts index 86e2f3b41a..c379c3fe4b 100644 --- a/packages/examples/packages/cronjobs/src/types.ts +++ b/packages/examples/packages/cronjobs/src/types.ts @@ -1,7 +1,17 @@ +/** + * The parameters for calling the `scheduleNotification` JSON-RPC method. + * + * @property date - The ISO 8601 date of when the notification should be scheduled. + */ export type ScheduleNotificationParams = { date: string; }; +/** + * The parameters for calling the `cancelNotification` JSON-RPC method. + * + * @property id - The id of the notification event to cancel. + */ export type CancelNotificationParams = { id: string; }; diff --git a/packages/snaps-controllers/src/test-utils/controller.ts b/packages/snaps-controllers/src/test-utils/controller.ts index 2e56cf0184..d1d12b701c 100644 --- a/packages/snaps-controllers/src/test-utils/controller.ts +++ b/packages/snaps-controllers/src/test-utils/controller.ts @@ -677,6 +677,7 @@ export const getRestrictedCronjobControllerMessenger = ( 'SnapController:handleRequest', 'CronjobController:scheduleBackgroundEvent', 'CronjobController:cancelBackgroundEvent', + 'CronjobController:getBackgroundEvents', ], }); From 385668c572d54d994d93248bedf0d04b707391a2 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Tue, 17 Dec 2024 13:28:45 -0500 Subject: [PATCH 34/38] add assert and remove cast --- .../src/permitted/scheduleBackgroundEvent.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.ts b/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.ts index ff08f0e4f2..99e7ce2813 100644 --- a/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.ts +++ b/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.ts @@ -18,7 +18,7 @@ import { refine, string, } from '@metamask/superstruct'; -import { type PendingJsonRpcResponse } from '@metamask/utils'; +import { assert, type PendingJsonRpcResponse } from '@metamask/utils'; import { DateTime } from 'luxon'; import { SnapEndowments } from '../endowments'; @@ -114,7 +114,9 @@ async function getScheduleBackgroundEventImplementation( .startOf('second') .toISO({ suppressMilliseconds: true, - }) as string; + }); + + assert(truncatedDate); const id = scheduleBackgroundEvent({ date: truncatedDate, request }); res.result = id; From 1554287f66ff38b96fa5e6c5079689e4b19bab34 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Thu, 19 Dec 2024 09:10:18 -0500 Subject: [PATCH 35/38] address nits --- packages/snaps-controllers/src/cronjob/CronjobController.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/snaps-controllers/src/cronjob/CronjobController.ts b/packages/snaps-controllers/src/cronjob/CronjobController.ts index c4620d1d8e..5d76813ce5 100644 --- a/packages/snaps-controllers/src/cronjob/CronjobController.ts +++ b/packages/snaps-controllers/src/cronjob/CronjobController.ts @@ -320,18 +320,20 @@ export class CronjobController extends BaseController< scheduleBackgroundEvent( backgroundEventWithoutId: Omit, ) { - // removing milliseond precision and converting to UTC. + // Remove millisecond precision and convert to UTC. const scheduledAt = DateTime.fromJSDate(new Date()) .toUTC() .startOf('second') .toISO({ suppressMilliseconds: true, - }) as string; + }); + const event = { ...backgroundEventWithoutId, id: nanoid(), scheduledAt, }; + this.#setUpBackgroundEvent(event); this.update((state) => { state.events[event.id] = castDraft(event); From c35611cc79388200ef58f2a75e4e7d5d33785fab Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Thu, 19 Dec 2024 09:18:37 -0500 Subject: [PATCH 36/38] increase coverage --- packages/snaps-rpc-methods/jest.config.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/snaps-rpc-methods/jest.config.js b/packages/snaps-rpc-methods/jest.config.js index e8bc740d12..fa993ad89b 100644 --- a/packages/snaps-rpc-methods/jest.config.js +++ b/packages/snaps-rpc-methods/jest.config.js @@ -10,10 +10,10 @@ module.exports = deepmerge(baseConfig, { ], coverageThreshold: { global: { - branches: 93.6, - functions: 97.9, - lines: 98.59, - statements: 98.12, + branches: 93.91, + functions: 98.02, + lines: 98.65, + statements: 98.24, }, }, }); From 854357686f1e992a3873b47fce43be7ad992de03 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Thu, 19 Dec 2024 09:22:39 -0500 Subject: [PATCH 37/38] add assert --- packages/snaps-controllers/src/cronjob/CronjobController.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/snaps-controllers/src/cronjob/CronjobController.ts b/packages/snaps-controllers/src/cronjob/CronjobController.ts index 5d76813ce5..f93995c9c2 100644 --- a/packages/snaps-controllers/src/cronjob/CronjobController.ts +++ b/packages/snaps-controllers/src/cronjob/CronjobController.ts @@ -328,6 +328,8 @@ export class CronjobController extends BaseController< suppressMilliseconds: true, }); + assert(scheduledAt); + const event = { ...backgroundEventWithoutId, id: nanoid(), From 036e154566d4b3c0e0cadf10566789db4aeea0f8 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Thu, 19 Dec 2024 09:24:32 -0500 Subject: [PATCH 38/38] rebuild --- packages/examples/packages/browserify-plugin/snap.manifest.json | 2 +- packages/examples/packages/browserify/snap.manifest.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/examples/packages/browserify-plugin/snap.manifest.json b/packages/examples/packages/browserify-plugin/snap.manifest.json index d202bd3826..c38dc1cfce 100644 --- a/packages/examples/packages/browserify-plugin/snap.manifest.json +++ b/packages/examples/packages/browserify-plugin/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snaps.git" }, "source": { - "shasum": "9v14rgSPwHyW1Wxg94LVBTC3pya/0MNBiQiULxcjdAI=", + "shasum": "hy0TMeQeqznNQRX2j7DnxRt1Nn5Z+v0rjaWNpe1fEWE=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/examples/packages/browserify/snap.manifest.json b/packages/examples/packages/browserify/snap.manifest.json index 72e3a9edc5..cd6594a575 100644 --- a/packages/examples/packages/browserify/snap.manifest.json +++ b/packages/examples/packages/browserify/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snaps.git" }, "source": { - "shasum": "w+jasFs5zm0dc8qZhW0rM+xOWJC4Y8kBsGj89kSEQhM=", + "shasum": "VR3Zwjo0yqKLkuKHGDfS9AmuyW3KMbXSmi9Nh9JgCMw=", "location": { "npm": { "filePath": "dist/bundle.js",