diff --git a/packages/snaps-controllers/coverage.json b/packages/snaps-controllers/coverage.json index ccd5052972..66bbedb6b1 100644 --- a/packages/snaps-controllers/coverage.json +++ b/packages/snaps-controllers/coverage.json @@ -1,6 +1,6 @@ { - "branches": 95.26, - "functions": 98.42, - "lines": 98.79, - "statements": 98.62 + "branches": 95.29, + "functions": 98.65, + "lines": 98.83, + "statements": 98.66 } diff --git a/packages/snaps-controllers/package.json b/packages/snaps-controllers/package.json index 862a7a1092..c27bcbc367 100644 --- a/packages/snaps-controllers/package.json +++ b/packages/snaps-controllers/package.json @@ -98,6 +98,7 @@ "@xstate/fsm": "^2.0.0", "async-mutex": "^0.5.0", "concat-stream": "^2.0.0", + "cron-parser": "^4.5.0", "fast-deep-equal": "^3.1.3", "get-npm-tarball-url": "^2.0.3", "immer": "^9.0.6", diff --git a/packages/snaps-controllers/src/cronjob/CronjobController.test.ts b/packages/snaps-controllers/src/cronjob/CronjobController.test.ts index e094bab90b..5fa23d879c 100644 --- a/packages/snaps-controllers/src/cronjob/CronjobController.test.ts +++ b/packages/snaps-controllers/src/cronjob/CronjobController.test.ts @@ -6,13 +6,14 @@ import type { SemVerVersion } from '@metamask/utils'; import { Duration, inMilliseconds } from '@metamask/utils'; import { CronjobController } from './CronjobController'; +import { METAMASK_ORIGIN } from '../snaps/constants'; import { getRestrictedCronjobControllerMessenger, getRootCronjobControllerMessenger, } from '../test-utils'; import { getCronjobPermission } from '../test-utils/cronjob'; -const MOCK_VERSION = '1.0' as SemVerVersion; +const MOCK_VERSION = '1.0.0' as SemVerVersion; describe('CronjobController', () => { const originalProcessNextTick = process.nextTick; @@ -25,11 +26,22 @@ describe('CronjobController', () => { jest.useRealTimers(); }); - it('registers a cronjob', () => { + it('registers a cronjob with an expression', () => { const rootMessenger = getRootCronjobControllerMessenger(); const controllerMessenger = getRestrictedCronjobControllerMessenger(rootMessenger); + rootMessenger.registerActionHandler( + 'PermissionController:getPermissions', + () => { + return { + [SnapEndowments.Cronjob]: getCronjobPermission({ + expression: '* * * * *', + }), + }; + }, + ); + const cronjobController = new CronjobController({ messenger: controllerMessenger, }); @@ -44,14 +56,14 @@ describe('CronjobController', () => { jest.advanceTimersByTime(inMilliseconds(1, Duration.Minute)); expect(rootMessenger.call).toHaveBeenNthCalledWith( - 4, + 2, 'SnapController:handleRequest', { snapId: MOCK_SNAP_ID, origin: 'metamask', handler: HandlerType.OnCronjob, request: { - method: 'exampleMethodOne', + method: 'exampleMethod', params: ['p1'], }, }, @@ -60,29 +72,44 @@ describe('CronjobController', () => { cronjobController.destroy(); }); - it('unregisters a cronjob', () => { + it('registers a cronjob with a duration', () => { const rootMessenger = getRootCronjobControllerMessenger(); const controllerMessenger = getRestrictedCronjobControllerMessenger(rootMessenger); + rootMessenger.registerActionHandler( + 'PermissionController:getPermissions', + () => { + return { + [SnapEndowments.Cronjob]: getCronjobPermission({ + duration: 'PT1M', + }), + }; + }, + ); + const cronjobController = new CronjobController({ messenger: controllerMessenger, }); cronjobController.register(MOCK_SNAP_ID); - cronjobController.unregister(MOCK_SNAP_ID); + + expect(rootMessenger.call).toHaveBeenCalledWith( + 'PermissionController:getPermissions', + MOCK_SNAP_ID, + ); jest.advanceTimersByTime(inMilliseconds(1, Duration.Minute)); - expect(rootMessenger.call).not.toHaveBeenNthCalledWith( - 4, + expect(rootMessenger.call).toHaveBeenNthCalledWith( + 2, 'SnapController:handleRequest', { snapId: MOCK_SNAP_ID, - origin: 'metamask', + origin: METAMASK_ORIGIN, handler: HandlerType.OnCronjob, request: { - method: 'exampleMethodOne', + method: 'exampleMethod', params: ['p1'], }, }, @@ -91,42 +118,72 @@ describe('CronjobController', () => { cronjobController.destroy(); }); - it('executes cronjobs that were missed during daily check in', () => { + it('unregisters a cronjob', () => { const rootMessenger = getRootCronjobControllerMessenger(); const controllerMessenger = getRestrictedCronjobControllerMessenger(rootMessenger); - rootMessenger.registerActionHandler( - 'PermissionController:getPermissions', - () => { - return { [SnapEndowments.Cronjob]: getCronjobPermission() }; + const cronjobController = new CronjobController({ + messenger: controllerMessenger, + }); + + cronjobController.register(MOCK_SNAP_ID); + cronjobController.unregister(MOCK_SNAP_ID); + + jest.advanceTimersByTime(inMilliseconds(1, Duration.Minute)); + + expect(rootMessenger.call).not.toHaveBeenCalledWith( + 'SnapController:handleRequest', + { + snapId: MOCK_SNAP_ID, + origin: METAMASK_ORIGIN, + handler: HandlerType.OnCronjob, + request: { + method: 'exampleMethodOne', + params: ['p1'], + }, }, ); + cronjobController.destroy(); + }); + + it('immediately executes cronjobs that are past the scheduled execution date', () => { + const rootMessenger = getRootCronjobControllerMessenger(); + const controllerMessenger = + getRestrictedCronjobControllerMessenger(rootMessenger); + const cronjobController = new CronjobController({ messenger: controllerMessenger, }); - // Update state manually for test - // @ts-expect-error Accessing private property + // @ts-expect-error: `update` is protected. cronjobController.update(() => { return { - jobs: { - [`${MOCK_SNAP_ID}-0`]: { lastRun: 0 }, + events: { + [`cronjob-${MOCK_SNAP_ID}-0`]: { + id: `cronjob-${MOCK_SNAP_ID}-0`, + snapId: MOCK_SNAP_ID, + date: new Date('2022-01-01T00:00Z').toISOString(), + scheduledAt: new Date('2022-01-01T00:00Z').toISOString(), + schedule: 'PT25H', + recurring: true, + request: { + method: 'exampleMethod', + params: ['p1'], + }, + }, }, - events: {}, }; }); - cronjobController.dailyCheckIn(); - jest.advanceTimersByTime(inMilliseconds(24, Duration.Hour)); expect(rootMessenger.call).toHaveBeenCalledWith( 'SnapController:handleRequest', { snapId: MOCK_SNAP_ID, - origin: 'metamask', + origin: METAMASK_ORIGIN, handler: HandlerType.OnCronjob, request: { method: 'exampleMethod', @@ -138,24 +195,12 @@ describe('CronjobController', () => { cronjobController.destroy(); }); - it('executes cronjobs that were missed during daily check in but doesnt repeat every init', async () => { + it('immediately executes cronjobs that are past the scheduled execution date and reschedules the cronjob', async () => { const rootMessenger = getRootCronjobControllerMessenger(); const controllerMessenger = getRestrictedCronjobControllerMessenger(rootMessenger); - rootMessenger.registerActionHandler( - 'PermissionController:getPermissions', - () => { - return { - [SnapEndowments.Cronjob]: getCronjobPermission({ - expression: '30 * * * *', - }), - }; - }, - ); - - const handleRequest = jest.fn(); - + const handleRequest = jest.fn().mockResolvedValue(undefined); rootMessenger.registerActionHandler( 'SnapController:handleRequest', handleRequest, @@ -164,20 +209,29 @@ describe('CronjobController', () => { const cronjobController = new CronjobController({ messenger: controllerMessenger, state: { - jobs: { - [`${MOCK_SNAP_ID}-0`]: { lastRun: 0 }, + events: { + [`cronjob-${MOCK_SNAP_ID}-0`]: { + id: `cronjob-${MOCK_SNAP_ID}-0`, + snapId: MOCK_SNAP_ID, + date: new Date('2022-01-01T00:00Z').toISOString(), + scheduledAt: new Date('2022-01-01T00:00Z').toISOString(), + schedule: 'PT25H', + recurring: true, + request: { + method: 'exampleMethod', + params: ['p1'], + }, + }, }, - events: {}, }, }); await new Promise((resolve) => originalProcessNextTick(resolve)); - expect(rootMessenger.call).toHaveBeenCalledWith( 'SnapController:handleRequest', { snapId: MOCK_SNAP_ID, - origin: 'metamask', + origin: METAMASK_ORIGIN, handler: HandlerType.OnCronjob, request: { method: 'exampleMethod', @@ -186,49 +240,16 @@ describe('CronjobController', () => { }, ); - const cronjobController2 = new CronjobController({ + const secondCronjobController = new CronjobController({ messenger: controllerMessenger, state: cronjobController.state, }); await new Promise((resolve) => originalProcessNextTick(resolve)); - expect(handleRequest).toHaveBeenCalledTimes(1); cronjobController.destroy(); - cronjobController2.destroy(); - }); - - it('catches errors during daily check in', () => { - const rootMessenger = getRootCronjobControllerMessenger(); - const controllerMessenger = - getRestrictedCronjobControllerMessenger(rootMessenger); - - rootMessenger.registerActionHandler( - 'PermissionController:getPermissions', - () => { - return { [SnapEndowments.Cronjob]: getCronjobPermission() }; - }, - ); - - const handleRequest = jest.fn().mockRejectedValue('Snap failed to boot.'); - - rootMessenger.registerActionHandler( - 'SnapController:handleRequest', - handleRequest, - ); - - const cronjobController = new CronjobController({ - messenger: controllerMessenger, - }); - - cronjobController.dailyCheckIn(); - - jest.advanceTimersByTime(inMilliseconds(24, Duration.Hour)); - - expect(handleRequest).toHaveBeenCalledTimes(2); - - cronjobController.destroy(); + secondCronjobController.destroy(); }); it('does not schedule cronjob that is too far in the future', () => { @@ -252,19 +273,19 @@ describe('CronjobController', () => { }); cronjobController.register(MOCK_SNAP_ID); + jest.runOnlyPendingTimers(); + expect(rootMessenger.call).toHaveBeenCalledTimes(1); expect(rootMessenger.call).toHaveBeenCalledWith( 'PermissionController:getPermissions', MOCK_SNAP_ID, ); - jest.runOnlyPendingTimers(); - expect(rootMessenger.call).not.toHaveBeenCalledWith( 'SnapController:handleRequest', { snapId: MOCK_SNAP_ID, - origin: 'metamask', + origin: METAMASK_ORIGIN, handler: HandlerType.OnCronjob, request: { method: 'exampleMethod', @@ -276,110 +297,119 @@ describe('CronjobController', () => { cronjobController.destroy(); }); - it('schedules a background event', () => { + it('schedules jobs that were not scheduled due to the daily timeout', () => { + const expression = '0 0 4 * *'; // At 12:00am on the 4th of every month. + const rootMessenger = getRootCronjobControllerMessenger(); const controllerMessenger = getRestrictedCronjobControllerMessenger(rootMessenger); + rootMessenger.registerActionHandler( + 'PermissionController:getPermissions', + () => { + return { + [SnapEndowments.Cronjob]: getCronjobPermission({ expression }), + }; + }, + ); + const cronjobController = new CronjobController({ messenger: controllerMessenger, }); - const backgroundEvent = { - snapId: MOCK_SNAP_ID, - date: '2022-01-01T01:00Z', - request: { - method: 'handleEvent', - params: ['p1'], - }, - }; - - const id = cronjobController.scheduleBackgroundEvent(backgroundEvent); + cronjobController.register(MOCK_SNAP_ID); - expect(cronjobController.state.events).toStrictEqual({ - [id]: { id, scheduledAt: expect.any(String), ...backgroundEvent }, - }); + jest.advanceTimersByTime(inMilliseconds(1, Duration.Day)); + expect(rootMessenger.call).toHaveBeenCalledTimes(1); + expect(rootMessenger.call).toHaveBeenCalledWith( + 'PermissionController:getPermissions', + MOCK_SNAP_ID, + ); jest.advanceTimersByTime(inMilliseconds(1, Duration.Day)); + expect(rootMessenger.call).toHaveBeenCalledTimes(1); - expect(rootMessenger.call).toHaveBeenCalledWith( + jest.advanceTimersByTime(inMilliseconds(1, Duration.Day)); + expect(rootMessenger.call).toHaveBeenCalledTimes(2); + expect(rootMessenger.call).toHaveBeenNthCalledWith( + 2, 'SnapController:handleRequest', { snapId: MOCK_SNAP_ID, - origin: 'metamask', + origin: METAMASK_ORIGIN, handler: HandlerType.OnCronjob, request: { - method: 'handleEvent', + method: 'exampleMethod', params: ['p1'], }, }, ); - expect(cronjobController.state.events).toStrictEqual({}); - cronjobController.destroy(); }); - it('fails to schedule a background event if the date is in the past', () => { + it('does not schedule events for a Snap without a permission caveat', () => { const rootMessenger = getRootCronjobControllerMessenger(); const controllerMessenger = getRestrictedCronjobControllerMessenger(rootMessenger); + rootMessenger.registerActionHandler( + 'PermissionController:getPermissions', + () => { + return { + [SnapEndowments.Cronjob]: { + date: 1664187844588, + id: 'izn0WGUO8cvq_jqvLQuQP', + invoker: MOCK_ORIGIN, + parentCapability: SnapEndowments.Cronjob, + caveats: null, + }, + }; + }, + ); + 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.'); - + cronjobController.register(MOCK_SNAP_ID); expect(cronjobController.state.events).toStrictEqual({}); cronjobController.destroy(); }); - it('cancels a background event', () => { + 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, - }); - - const backgroundEvent = { - snapId: MOCK_SNAP_ID, - date: '2022-01-01T01:00Z', - request: { - method: 'handleEvent', - params: ['p1'], + state: { + events: { + foo: { + id: 'foo', + recurring: false, + date: '2022-01-01T01:00Z', + schedule: '2022-01-01T01:00Z', + scheduledAt: new Date().toISOString(), + snapId: MOCK_SNAP_ID, + request: { + method: 'handleEvent', + params: ['p1'], + }, + }, + }, }, - }; - - const id = cronjobController.scheduleBackgroundEvent(backgroundEvent); - - expect(cronjobController.state.events).toStrictEqual({ - [id]: { id, scheduledAt: expect.any(String), ...backgroundEvent }, }); - cronjobController.cancelBackgroundEvent(MOCK_SNAP_ID, id); - jest.advanceTimersByTime(inMilliseconds(1, Duration.Day)); - expect(rootMessenger.call).not.toHaveBeenCalledWith( + expect(rootMessenger.call).toHaveBeenCalledWith( 'SnapController:handleRequest', { snapId: MOCK_SNAP_ID, - origin: 'metamask', + origin: METAMASK_ORIGIN, handler: HandlerType.OnCronjob, request: { method: 'handleEvent', @@ -393,7 +423,7 @@ describe('CronjobController', () => { cronjobController.destroy(); }); - it('fails to cancel a background event if the caller is not the scheduler', () => { + it('handles the `snapInstalled` event', () => { const rootMessenger = getRootCronjobControllerMessenger(); const controllerMessenger = getRestrictedCronjobControllerMessenger(rootMessenger); @@ -402,166 +432,67 @@ describe('CronjobController', () => { messenger: controllerMessenger, }); - const backgroundEvent = { - snapId: MOCK_SNAP_ID, - date: '2022-01-01T01:00Z', - request: { - method: 'handleEvent', - params: ['p1'], - }, + const snapInfo: TruncatedSnap = { + blocked: false, + enabled: true, + id: MOCK_SNAP_ID, + initialPermissions: {}, + version: MOCK_VERSION, }; - const id = cronjobController.scheduleBackgroundEvent(backgroundEvent); - - expect(cronjobController.state.events).toStrictEqual({ - [id]: { id, scheduledAt: expect.any(String), ...backgroundEvent }, - }); - - expect(() => cronjobController.cancelBackgroundEvent('foo', id)).toThrow( - 'Only the origin that scheduled this event can cancel it.', + rootMessenger.publish( + 'SnapController:snapInstalled', + snapInfo, + MOCK_ORIGIN, + false, ); - 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: '2025-05-21T13:25:21.500Z', - request: { - method: 'handleEvent', - params: ['p1'], - }, - }; - - const id = cronjobController.scheduleBackgroundEvent(backgroundEvent); + jest.advanceTimersByTime(inMilliseconds(1, Duration.Minute)); - const events = cronjobController.getBackgroundEvents(MOCK_SNAP_ID); - expect(events).toStrictEqual([ + expect(rootMessenger.call).toHaveBeenNthCalledWith( + 2, + 'SnapController:handleRequest', { - id, snapId: MOCK_SNAP_ID, - date: '2025-05-21T13:25:21Z', + origin: METAMASK_ORIGIN, + handler: HandlerType.OnCronjob, request: { - method: 'handleEvent', + method: 'exampleMethodOne', params: ['p1'], }, - scheduledAt: expect.any(String), }, - ]); + ); cronjobController.destroy(); }); - it('reschedules any un-expired events that are in state upon initialization', () => { + it('handles the `snapEnabled` event', () => { const rootMessenger = getRootCronjobControllerMessenger(); const controllerMessenger = getRestrictedCronjobControllerMessenger(rootMessenger); + rootMessenger.registerActionHandler( + 'PermissionController:getPermissions', + () => { + return { + [SnapEndowments.Cronjob]: getCronjobPermission({ + expression: '0 0 * * *', + }), + }; + }, + ); + const cronjobController = new CronjobController({ messenger: controllerMessenger, state: { - jobs: {}, events: { foo: { id: 'foo', - scheduledAt: new Date().toISOString(), - snapId: MOCK_SNAP_ID, + recurring: false, date: '2022-01-01T01:00Z', - request: { - method: 'handleEvent', - params: ['p1'], - }, - }, - }, - }, - }); - - jest.advanceTimersByTime(inMilliseconds(1, Duration.Day)); - - expect(rootMessenger.call).toHaveBeenCalledWith( - 'SnapController:handleRequest', - { - snapId: MOCK_SNAP_ID, - origin: 'metamask', - handler: HandlerType.OnCronjob, - request: { - method: 'handleEvent', - params: ['p1'], - }, - }, - ); - - expect(cronjobController.state.events).toStrictEqual({}); - - cronjobController.destroy(); - }); - - it('handles SnapInstalled event', () => { - const rootMessenger = getRootCronjobControllerMessenger(); - const controllerMessenger = - getRestrictedCronjobControllerMessenger(rootMessenger); - - const cronjobController = new CronjobController({ - messenger: controllerMessenger, - }); - - const snapInfo: TruncatedSnap = { - blocked: false, - enabled: true, - id: MOCK_SNAP_ID, - initialPermissions: {}, - version: MOCK_VERSION, - }; - - rootMessenger.publish( - 'SnapController:snapInstalled', - snapInfo, - MOCK_ORIGIN, - ); - - jest.advanceTimersByTime(inMilliseconds(1, Duration.Minute)); - - expect(rootMessenger.call).toHaveBeenNthCalledWith( - 4, - 'SnapController:handleRequest', - { - snapId: MOCK_SNAP_ID, - origin: 'metamask', - handler: HandlerType.OnCronjob, - request: { - method: 'exampleMethodOne', - params: ['p1'], - }, - }, - ); - - cronjobController.destroy(); - }); - - it('handles SnapEnabled event', () => { - const rootMessenger = getRootCronjobControllerMessenger(); - const controllerMessenger = - getRestrictedCronjobControllerMessenger(rootMessenger); - - const cronjobController = new CronjobController({ - messenger: controllerMessenger, - state: { - jobs: {}, - events: { - foo: { - id: 'foo', + schedule: '2022-01-01T01:00Z', scheduledAt: new Date().toISOString(), snapId: MOCK_SNAP_ID, - date: '2022-01-01T01:00Z', request: { method: 'handleEvent', params: ['p1'], @@ -569,9 +500,11 @@ describe('CronjobController', () => { }, bar: { id: 'bar', + recurring: false, + date: '2021-01-01T01:00Z', + schedule: '2021-01-01T01:00Z', scheduledAt: new Date().toISOString(), snapId: MOCK_SNAP_ID, - date: '2021-01-01T01:00Z', request: { method: 'handleEvent', params: ['p1'], @@ -592,11 +525,25 @@ describe('CronjobController', () => { rootMessenger.publish('SnapController:snapEnabled', snapInfo); expect(cronjobController.state.events).toStrictEqual({ + [`cronjob-${MOCK_SNAP_ID}-0`]: { + id: `cronjob-${MOCK_SNAP_ID}-0`, + recurring: true, + date: '2022-01-02T00:00:00.000Z', + schedule: '0 0 * * *', + scheduledAt: expect.any(String), + snapId: MOCK_SNAP_ID, + request: { + method: 'exampleMethod', + params: ['p1'], + }, + }, foo: { id: 'foo', + recurring: false, + date: '2022-01-01T01:00:00Z', + schedule: '2022-01-01T01:00Z', scheduledAt: new Date().toISOString(), snapId: MOCK_SNAP_ID, - date: '2022-01-01T01:00Z', request: { method: 'handleEvent', params: ['p1'], @@ -606,28 +553,30 @@ describe('CronjobController', () => { jest.advanceTimersByTime(inMilliseconds(1, Duration.Day)); + expect(rootMessenger.call).toHaveBeenCalledTimes(3); expect(rootMessenger.call).toHaveBeenNthCalledWith( - 4, + 2, 'SnapController:handleRequest', { snapId: MOCK_SNAP_ID, origin: 'metamask', handler: HandlerType.OnCronjob, request: { - method: 'exampleMethodOne', + method: 'handleEvent', params: ['p1'], }, }, ); - expect(rootMessenger.call).toHaveBeenCalledWith( + expect(rootMessenger.call).toHaveBeenNthCalledWith( + 3, 'SnapController:handleRequest', { snapId: MOCK_SNAP_ID, - origin: 'metamask', + origin: METAMASK_ORIGIN, handler: HandlerType.OnCronjob, request: { - method: 'handleEvent', + method: 'exampleMethod', params: ['p1'], }, }, @@ -636,7 +585,7 @@ describe('CronjobController', () => { cronjobController.destroy(); }); - it('handles SnapUninstalled event', () => { + it('handles the `snapUninstalled` event', () => { const rootMessenger = getRootCronjobControllerMessenger(); const controllerMessenger = getRestrictedCronjobControllerMessenger(rootMessenger); @@ -653,59 +602,22 @@ describe('CronjobController', () => { version: MOCK_VERSION, }; - cronjobController.scheduleBackgroundEvent({ + cronjobController.schedule({ snapId: MOCK_SNAP_ID, - date: '2022-01-01T01:00Z', + schedule: '2022-01-01T01:00Z', request: { method: 'handleEvent', params: ['p1'], }, }); - rootMessenger.publish( - 'SnapController:snapInstalled', - snapInfo, - MOCK_ORIGIN, - ); - rootMessenger.publish('SnapController:snapUninstalled', snapInfo); - - jest.advanceTimersByTime(inMilliseconds(1, Duration.Minute)); - - expect(rootMessenger.call).not.toHaveBeenCalledWith( - 'SnapController:handleRequest', - { - snapId: MOCK_SNAP_ID, - origin: 'metamask', - handler: HandlerType.OnCronjob, - request: { - method: 'exampleMethodOne', - params: ['p1'], - }, - }, - ); - - jest.advanceTimersByTime(inMilliseconds(1, Duration.Day)); - - expect(rootMessenger.call).not.toHaveBeenCalledWith( - 'SnapController:handleRequest', - { - snapId: MOCK_SNAP_ID, - origin: 'metamask', - handler: HandlerType.OnCronjob, - request: { - method: 'handleEvent', - params: ['p1'], - }, - }, - ); - expect(cronjobController.state.events).toStrictEqual({}); cronjobController.destroy(); }); - it('handles SnapDisabled event', () => { + it('handles the `snapDisabled` event', () => { const rootMessenger = getRootCronjobControllerMessenger(); const controllerMessenger = getRestrictedCronjobControllerMessenger(rootMessenger); @@ -722,70 +634,22 @@ describe('CronjobController', () => { version: MOCK_VERSION, }; - const id = cronjobController.scheduleBackgroundEvent({ + cronjobController.schedule({ snapId: MOCK_SNAP_ID, - date: '2022-01-01T01:00Z', + schedule: '2022-01-01T01:00Z', request: { method: 'handleEvent', params: ['p1'], }, }); - rootMessenger.publish( - 'SnapController:snapInstalled', - snapInfo, - MOCK_ORIGIN, - ); - rootMessenger.publish('SnapController:snapDisabled', snapInfo); - - jest.advanceTimersByTime(inMilliseconds(1, Duration.Minute)); - - expect(rootMessenger.call).not.toHaveBeenCalledWith( - 'SnapController:handleRequest', - { - snapId: MOCK_SNAP_ID, - origin: 'metamask', - handler: HandlerType.OnCronjob, - request: { - method: 'exampleMethodOne', - params: ['p1'], - }, - }, - ); - - jest.advanceTimersByTime(inMilliseconds(1, Duration.Day)); - - expect(rootMessenger.call).not.toHaveBeenCalledWith( - 'SnapController:handleRequest', - { - snapId: MOCK_SNAP_ID, - origin: 'metamask', - handler: HandlerType.OnCronjob, - request: { - method: 'handleEvent', - params: ['p1'], - }, - }, - ); - - expect(cronjobController.state.events).toStrictEqual({ - [id]: { - id, - scheduledAt: expect.any(String), - snapId: MOCK_SNAP_ID, - date: '2022-01-01T01:00Z', - request: { - method: 'handleEvent', - params: ['p1'], - }, - }, - }); + expect(cronjobController.state.events).toStrictEqual({}); cronjobController.destroy(); }); - it('handles SnapUpdated event', () => { + it('handles the `snapUpdated` event', () => { const rootMessenger = getRootCronjobControllerMessenger(); const controllerMessenger = getRestrictedCronjobControllerMessenger(rootMessenger); @@ -793,13 +657,26 @@ describe('CronjobController', () => { const cronjobController = new CronjobController({ messenger: controllerMessenger, state: { - jobs: {}, events: { + [`cronjob-${MOCK_SNAP_ID}-0`]: { + id: `cronjob-${MOCK_SNAP_ID}-0`, + recurring: true, + date: new Date('2022-01-01T00:00Z').toISOString(), + schedule: 'PT25H', + scheduledAt: new Date('2022-01-01T00:00Z').toISOString(), + snapId: MOCK_SNAP_ID, + request: { + method: 'exampleMethod', + params: ['p1'], + }, + }, foo: { id: 'foo', + recurring: false, + date: '2022-01-01T01:00Z', + schedule: '2022-01-01T01:00Z', scheduledAt: new Date().toISOString(), snapId: MOCK_SNAP_ID, - date: '2022-01-01T01:00Z', request: { method: 'handleEvent', params: ['p1'], @@ -817,55 +694,33 @@ describe('CronjobController', () => { version: MOCK_VERSION, }; - rootMessenger.publish( - 'SnapController:snapInstalled', - snapInfo, - MOCK_ORIGIN, - ); - rootMessenger.publish( 'SnapController:snapUpdated', snapInfo, snapInfo.version, MOCK_ORIGIN, + false, ); - expect(cronjobController.state.events).toStrictEqual({}); - - jest.advanceTimersByTime(inMilliseconds(15, Duration.Minute)); - - expect(rootMessenger.call).toHaveBeenNthCalledWith( - 5, - 'SnapController:handleRequest', - { + expect(cronjobController.state.events).toStrictEqual({ + [`cronjob-${MOCK_SNAP_ID}-0`]: { + id: `cronjob-${MOCK_SNAP_ID}-0`, + recurring: true, + date: '2022-01-01T00:01:00.000Z', + schedule: '* * * * *', + scheduledAt: expect.any(String), snapId: MOCK_SNAP_ID, - origin: 'metamask', - handler: HandlerType.OnCronjob, request: { method: 'exampleMethodOne', params: ['p1'], }, }, - ); - - expect(rootMessenger.call).not.toHaveBeenCalledWith( - 5, - 'SnapController:handleRequest', - { - snapId: MOCK_SNAP_ID, - origin: 'metamask', - handler: HandlerType.OnCronjob, - request: { - method: 'handleEvent', - params: ['p1'], - }, - }, - ); + }); cronjobController.destroy(); }); - it('removes all jobs and schedules after controller destroy is called', () => { + it('removes all events when the controller is destroyed', () => { const rootMessenger = getRootCronjobControllerMessenger(); const controllerMessenger = getRestrictedCronjobControllerMessenger(rootMessenger); @@ -876,194 +731,252 @@ describe('CronjobController', () => { cronjobController.register(MOCK_SNAP_ID); + cronjobController.destroy(); + + jest.advanceTimersByTime(inMilliseconds(1, Duration.Day)); + + expect(rootMessenger.call).toHaveBeenCalledTimes(1); expect(rootMessenger.call).toHaveBeenCalledWith( 'PermissionController:getPermissions', MOCK_SNAP_ID, ); + }); - cronjobController.destroy(); + it('logs errors caught during cronjob execution', async () => { + const rootMessenger = getRootCronjobControllerMessenger(); + const controllerMessenger = + getRestrictedCronjobControllerMessenger(rootMessenger); - jest.advanceTimersByTime(inMilliseconds(1, Duration.Minute)); + rootMessenger.registerActionHandler( + 'PermissionController:getPermissions', + () => { + return { + [SnapEndowments.Cronjob]: getCronjobPermission({ + expression: '* * * * *', + }), + }; + }, + ); - expect(rootMessenger.call).not.toHaveBeenCalledWith( + jest.spyOn(console, 'error').mockImplementation(); + + const cronjobController = new CronjobController({ + messenger: controllerMessenger, + }); + + const error = new Error('Test error.'); + rootMessenger.registerActionHandler( 'SnapController:handleRequest', - { - snapId: MOCK_SNAP_ID, - origin: 'metamask', - handler: HandlerType.OnCronjob, - request: { - method: 'exampleMethodOne', - params: ['p1'], - }, + async () => { + throw error; }, ); + + cronjobController.register(MOCK_SNAP_ID); + + jest.advanceTimersByTime(inMilliseconds(1, Duration.Minute)); + await new Promise((resolve) => originalProcessNextTick(resolve)); + + expect(console.error).toHaveBeenCalledWith( + `An error occurred while executing an event for Snap "${MOCK_SNAP_ID}":`, + error, + ); + + cronjobController.destroy(); }); - describe('CronjobController actions', () => { - describe('CronjobController:scheduleBackgroundEvent', () => { - it('schedules a background event', () => { - const rootMessenger = getRootCronjobControllerMessenger(); - const controllerMessenger = - getRestrictedCronjobControllerMessenger(rootMessenger); + describe('CronjobController:schedule', () => { + it('schedules a background event', () => { + const rootMessenger = getRootCronjobControllerMessenger(); + const controllerMessenger = + getRestrictedCronjobControllerMessenger(rootMessenger); - const cronjobController = new CronjobController({ - messenger: controllerMessenger, - }); + const cronjobController = new CronjobController({ + messenger: controllerMessenger, + }); - cronjobController.register(MOCK_SNAP_ID); + const event = { + snapId: MOCK_SNAP_ID, + schedule: '2022-01-01T01:00Z', + request: { + method: 'handleEvent', + params: ['p1'], + }, + }; - const id = rootMessenger.call( - 'CronjobController:scheduleBackgroundEvent', - { - snapId: MOCK_SNAP_ID, - date: '2022-01-01T01:00Z', - request: { - method: 'handleExport', - params: ['p1'], - }, - }, - ); + const id = rootMessenger.call('CronjobController:schedule', event); + expect(cronjobController.state.events).toStrictEqual({ + [id]: { + id, + recurring: false, + date: '2022-01-01T01:00:00Z', + scheduledAt: expect.any(String), + ...event, + }, + }); - expect(cronjobController.state.events).toStrictEqual({ - [id]: { - id, - snapId: MOCK_SNAP_ID, - scheduledAt: expect.any(String), - date: '2022-01-01T01:00Z', - request: { - method: 'handleExport', - params: ['p1'], - }, + jest.advanceTimersByTime(inMilliseconds(1, Duration.Day)); + + expect(rootMessenger.call).toHaveBeenCalledWith( + 'SnapController:handleRequest', + { + snapId: MOCK_SNAP_ID, + origin: METAMASK_ORIGIN, + handler: HandlerType.OnCronjob, + request: { + method: 'handleEvent', + params: ['p1'], }, - }); + }, + ); - jest.advanceTimersByTime(inMilliseconds(1, Duration.Day)); + expect(cronjobController.state.events).toStrictEqual({}); - expect(rootMessenger.call).toHaveBeenCalledWith( - 'SnapController:handleRequest', - { - snapId: MOCK_SNAP_ID, - origin: 'metamask', - handler: HandlerType.OnCronjob, - request: { - method: 'handleExport', - params: ['p1'], - }, - }, - ); + cronjobController.destroy(); + }); - expect(cronjobController.state.events).toStrictEqual({}); + it('throws when scheduling a background event if the date is in the past', () => { + const rootMessenger = getRootCronjobControllerMessenger(); + const controllerMessenger = + getRestrictedCronjobControllerMessenger(rootMessenger); - cronjobController.destroy(); + const cronjobController = new CronjobController({ + messenger: controllerMessenger, }); - }); - describe('CronjobController:cancelBackgroundEvent', () => { - it('cancels a background event', () => { - const rootMessenger = getRootCronjobControllerMessenger(); - const controllerMessenger = - getRestrictedCronjobControllerMessenger(rootMessenger); + const event = { + snapId: MOCK_SNAP_ID, + schedule: '2021-01-01T01:00Z', + request: { + method: 'handleEvent', + params: ['p1'], + }, + }; - const cronjobController = new CronjobController({ - messenger: controllerMessenger, - }); + expect(() => + rootMessenger.call('CronjobController:schedule', event), + ).toThrow('Cannot schedule an event in the past.'); - cronjobController.register(MOCK_SNAP_ID); + expect(cronjobController.state.events).toStrictEqual({}); - const id = rootMessenger.call( - 'CronjobController:scheduleBackgroundEvent', - { - snapId: MOCK_SNAP_ID, - date: '2022-01-01T01:00Z', - request: { - method: 'handleExport', - params: ['p1'], - }, - }, - ); + cronjobController.destroy(); + }); + }); - expect(cronjobController.state.events).toStrictEqual({ - [id]: { - id, - snapId: MOCK_SNAP_ID, - scheduledAt: expect.any(String), - date: '2022-01-01T01:00Z', - request: { - method: 'handleExport', - params: ['p1'], - }, - }, - }); + describe('CronjobController:cancel', () => { + it('cancels a background event', () => { + const rootMessenger = getRootCronjobControllerMessenger(); + const controllerMessenger = + getRestrictedCronjobControllerMessenger(rootMessenger); - rootMessenger.call( - 'CronjobController:cancelBackgroundEvent', - MOCK_SNAP_ID, - id, - ); + const cronjobController = new CronjobController({ + messenger: controllerMessenger, + }); - expect(cronjobController.state.events).toStrictEqual({}); + const event = { + snapId: MOCK_SNAP_ID, + schedule: '2022-01-01T01:00Z', + request: { + method: 'handleEvent', + params: ['p1'], + }, + }; - cronjobController.destroy(); + const id = rootMessenger.call('CronjobController:schedule', event); + expect(cronjobController.state.events).toStrictEqual({ + [id]: { + id, + recurring: false, + date: '2022-01-01T01:00:00Z', + scheduledAt: expect.any(String), + ...event, + }, }); + + rootMessenger.call('CronjobController:cancel', MOCK_SNAP_ID, id); + expect(cronjobController.state.events).toStrictEqual({}); + + jest.advanceTimersByTime(inMilliseconds(1, Duration.Day)); + expect(rootMessenger.call).toHaveBeenCalledTimes(2); + + cronjobController.destroy(); }); - describe('CronjobController:getBackgroundEvents', () => { - it("gets a list of a Snap's background events", () => { - const rootMessenger = getRootCronjobControllerMessenger(); - const controllerMessenger = - getRestrictedCronjobControllerMessenger(rootMessenger); + it('throws when cancelling an event scheduled by another origin', () => { + const rootMessenger = getRootCronjobControllerMessenger(); + const controllerMessenger = + getRestrictedCronjobControllerMessenger(rootMessenger); - const cronjobController = new CronjobController({ - messenger: controllerMessenger, - }); + const cronjobController = new CronjobController({ + messenger: controllerMessenger, + }); - cronjobController.register(MOCK_SNAP_ID); + const event = { + snapId: MOCK_SNAP_ID, + schedule: '2022-01-01T01:00Z', + request: { + method: 'handleEvent', + params: ['p1'], + }, + }; - const id = rootMessenger.call( - 'CronjobController:scheduleBackgroundEvent', - { - snapId: MOCK_SNAP_ID, - date: '2025-05-21T13:25:21.500Z', - request: { - method: 'handleExport', - params: ['p1'], - }, - }, - ); + const id = rootMessenger.call('CronjobController:schedule', event); + expect(cronjobController.state.events).toStrictEqual({ + [id]: { + id, + recurring: false, + date: '2022-01-01T01:00:00Z', + scheduledAt: expect.any(String), + ...event, + }, + }); - expect(cronjobController.state.events).toStrictEqual({ - [id]: { - id, - snapId: MOCK_SNAP_ID, - scheduledAt: expect.any(String), - date: '2025-05-21T13:25:21.500Z', - request: { - method: 'handleExport', - params: ['p1'], - }, - }, - }); + expect(() => + rootMessenger.call('CronjobController:cancel', 'foo', id), + ).toThrow('Only the origin that scheduled this event can cancel it.'); - const events = rootMessenger.call( - 'CronjobController:getBackgroundEvents', - MOCK_SNAP_ID, - ); + cronjobController.destroy(); + }); + }); - expect(events).toStrictEqual([ - { - id, - snapId: MOCK_SNAP_ID, - scheduledAt: expect.any(String), - date: '2025-05-21T13:25:21Z', - request: { - method: 'handleExport', - params: ['p1'], - }, - }, - ]); + describe('CronjobController:get', () => { + it("returns a list of a Snap's background events", () => { + const rootMessenger = getRootCronjobControllerMessenger(); + const controllerMessenger = + getRestrictedCronjobControllerMessenger(rootMessenger); - cronjobController.destroy(); + const cronjobController = new CronjobController({ + messenger: controllerMessenger, }); + + const event = { + snapId: MOCK_SNAP_ID, + schedule: '2025-05-21T13:25:21.500Z', + request: { + method: 'handleEvent', + params: ['p1'], + }, + }; + + const id = cronjobController.schedule(event); + + const events = rootMessenger.call('CronjobController:get', MOCK_SNAP_ID); + expect(events).toStrictEqual([ + { + id, + snapId: MOCK_SNAP_ID, + date: '2025-05-21T13:25:21Z', + recurring: false, + request: { + method: 'handleEvent', + params: ['p1'], + }, + schedule: '2025-05-21T13:25:21.500Z', + scheduledAt: expect.any(String), + }, + ]); + + cronjobController.destroy(); }); }); }); diff --git a/packages/snaps-controllers/src/cronjob/CronjobController.ts b/packages/snaps-controllers/src/cronjob/CronjobController.ts index d5990be9eb..6c4b1a71c1 100644 --- a/packages/snaps-controllers/src/cronjob/CronjobController.ts +++ b/packages/snaps-controllers/src/cronjob/CronjobController.ts @@ -10,24 +10,19 @@ import { SnapEndowments, } from '@metamask/snaps-rpc-methods'; import type { BackgroundEvent, SnapId } from '@metamask/snaps-sdk'; -import type { - TruncatedSnap, - CronjobSpecification, -} from '@metamask/snaps-utils'; +import type { TruncatedSnap } from '@metamask/snaps-utils'; import { + toCensoredISO8601String, HandlerType, - parseCronExpression, logError, - logWarning, - toCensoredISO8601String, } from '@metamask/snaps-utils'; -import { assert, Duration, hasProperty, inMilliseconds } from '@metamask/utils'; +import { assert, Duration, inMilliseconds } from '@metamask/utils'; import { castDraft } from 'immer'; import { DateTime } from 'luxon'; import { nanoid } from 'nanoid'; +import { getCronjobSpecificationSchedule, getExecutionDate } from './utils'; import type { - GetAllSnaps, HandleSnapRequest, SnapDisabled, SnapEnabled, @@ -35,7 +30,7 @@ import type { SnapUninstalled, SnapUpdated, } from '..'; -import { getRunnableSnaps } from '..'; +import { METAMASK_ORIGIN } from '../snaps/constants'; import { Timer } from '../snaps/Timer'; export type CronjobControllerGetStateAction = ControllerGetStateAction< @@ -47,37 +42,36 @@ export type CronjobControllerStateChangeEvent = ControllerStateChangeEvent< CronjobControllerState >; -export type ScheduleBackgroundEvent = { - type: `${typeof controllerName}:scheduleBackgroundEvent`; - handler: CronjobController['scheduleBackgroundEvent']; +export type Schedule = { + type: `${typeof controllerName}:schedule`; + handler: CronjobController['schedule']; }; -export type CancelBackgroundEvent = { - type: `${typeof controllerName}:cancelBackgroundEvent`; - handler: CronjobController['cancelBackgroundEvent']; +export type Cancel = { + type: `${typeof controllerName}:cancel`; + handler: CronjobController['cancel']; }; -export type GetBackgroundEvents = { - type: `${typeof controllerName}:getBackgroundEvents`; - handler: CronjobController['getBackgroundEvents']; +export type Get = { + type: `${typeof controllerName}:get`; + handler: CronjobController['get']; }; export type CronjobControllerActions = - | GetAllSnaps + | CronjobControllerGetStateAction | HandleSnapRequest | GetPermissions - | CronjobControllerGetStateAction - | ScheduleBackgroundEvent - | CancelBackgroundEvent - | GetBackgroundEvents; + | Schedule + | Cancel + | Get; export type CronjobControllerEvents = + | CronjobControllerStateChangeEvent | SnapInstalled | SnapUninstalled | SnapUpdated | SnapEnabled - | SnapDisabled - | CronjobControllerStateChangeEvent; + | SnapDisabled; export type CronjobControllerMessenger = RestrictedMessenger< typeof controllerName, @@ -91,542 +85,507 @@ export const DAILY_TIMEOUT = inMilliseconds(24, Duration.Hour); export type CronjobControllerArgs = { messenger: CronjobControllerMessenger; + /** * Persisted state that will be used for rehydration. */ state?: CronjobControllerState; }; -export type Cronjob = { - timer?: Timer; - id: string; - snapId: SnapId; -} & CronjobSpecification; +/** + * Represents a background event that is scheduled to be executed by the + * cronjob controller. + */ +export type InternalBackgroundEvent = BackgroundEvent & { + /** + * Whether the event is recurring. + */ + recurring: boolean; + + /** + * The cron expression or ISO 8601 duration string that defines the event's + * schedule. + */ + schedule: string; +}; -export type StoredJobInformation = { - lastRun: number; +/** + * A schedulable background event, which is a subset of the + * {@link InternalBackgroundEvent} type, containing only the fields required to + * schedule an event. Other fields will be populated by the cronjob controller + * automatically. + */ +export type SchedulableBackgroundEvent = Omit< + InternalBackgroundEvent, + 'scheduledAt' | 'date' | 'id' +> & { + /** + * The optional ID of the event. If not provided, a new ID will be + * generated. + */ + id?: string; }; export type CronjobControllerState = { - jobs: Record; - events: Record; + /** + * Background events and cronjobs that are scheduled to be executed. + */ + events: Record; }; const controllerName = 'CronjobController'; /** - * Use this controller to register and schedule periodically executed jobs - * using RPC method hooks. + * The cronjob controller is responsible for managing cronjobs and background + * events for Snaps. It allows Snaps to schedule events that will be executed + * at a later time. */ export class CronjobController extends BaseController< typeof controllerName, CronjobControllerState, CronjobControllerMessenger > { - #dailyTimer!: Timer; - readonly #timers: Map; - // Mapping from jobId to snapId - readonly #snapIds: Map; + #dailyTimer: Timer = new Timer(DAILY_TIMEOUT); constructor({ messenger, state }: CronjobControllerArgs) { super({ messenger, metadata: { - jobs: { persist: true, anonymous: false }, events: { persist: true, anonymous: false }, }, name: controllerName, state: { - jobs: {}, events: {}, ...state, }, }); - this.#timers = new Map(); - this.#snapIds = new Map(); - 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 */ + this.#timers = new Map(); this.messagingSystem.subscribe( 'SnapController:snapInstalled', - this._handleSnapRegisterEvent, + this.#handleSnapInstalledEvent, ); this.messagingSystem.subscribe( 'SnapController:snapUninstalled', - this._handleSnapUnregisterEvent, + this.#handleSnapUninstalledEvent, ); this.messagingSystem.subscribe( 'SnapController:snapEnabled', - this._handleSnapEnabledEvent, + this.#handleSnapEnabledEvent, ); this.messagingSystem.subscribe( 'SnapController:snapDisabled', - this._handleSnapDisabledEvent, + this.#handleSnapDisabledEvent, ); this.messagingSystem.subscribe( 'SnapController:snapUpdated', - this._handleEventSnapUpdated, + this.#handleSnapUpdatedEvent, ); - /* eslint-enable @typescript-eslint/unbound-method */ this.messagingSystem.registerActionHandler( - `${controllerName}:scheduleBackgroundEvent`, - (...args) => this.scheduleBackgroundEvent(...args), + `${controllerName}:schedule`, + (...args) => this.schedule(...args), ); this.messagingSystem.registerActionHandler( - `${controllerName}:cancelBackgroundEvent`, - (...args) => this.cancelBackgroundEvent(...args), + `${controllerName}:cancel`, + (...args) => this.cancel(...args), ); this.messagingSystem.registerActionHandler( - `${controllerName}:getBackgroundEvents`, - (...args) => this.getBackgroundEvents(...args), + `${controllerName}:get`, + (...args) => this.get(...args), ); - this.dailyCheckIn(); - - this.#rescheduleBackgroundEvents(Object.values(this.state.events)); + this.#start(); + this.#clear(); + this.#reschedule(); } /** - * Retrieve all cronjob specifications for all runnable snaps. + * Schedule a non-recurring background event. * - * @returns Array of Cronjob specifications. + * @param event - The event to schedule. + * @returns The ID of the scheduled event. */ - #getAllJobs(): Cronjob[] { - const snaps = this.messagingSystem.call('SnapController:getAll'); - const filteredSnaps = getRunnableSnaps(snaps); - - const jobs = filteredSnaps.map((snap) => this.#getSnapJobs(snap.id)); - return jobs.flat().filter((job) => job !== undefined) as Cronjob[]; + schedule(event: Omit) { + return this.#add({ + ...event, + recurring: false, + }); } /** - * Retrieve all Cronjob specifications for a Snap. + * Cancel an event. * - * @param snapId - ID of a Snap. - * @returns Array of Cronjob specifications. + * @param origin - The origin making the cancel call. + * @param id - The id of the event to cancel. + * @throws If the event does not exist. */ - #getSnapJobs(snapId: SnapId): Cronjob[] | undefined { - const permissions = this.messagingSystem.call( - 'PermissionController:getPermissions', - snapId, + cancel(origin: string, id: string) { + assert( + this.state.events[id], + `A background event with the id of "${id}" does not exist.`, ); - const permission = permissions?.[SnapEndowments.Cronjob]; - const definitions = getCronjobCaveatJobs(permission); + assert( + this.state.events[id].snapId === origin, + 'Only the origin that scheduled this event can cancel it.', + ); - return definitions?.map((definition, idx) => { - return { ...definition, id: `${snapId}-${idx}`, snapId }; - }); + this.#cancel(id); } /** - * Register cron jobs for a given snap by getting specification from a permission caveats. - * Once registered, each job will be scheduled. + * Get a list of a Snap's background events. * - * @param snapId - ID of a snap. + * @param snapId - The id of the Snap to fetch background events for. + * @returns An array of background events. */ - register(snapId: SnapId) { - const jobs = this.#getSnapJobs(snapId); - jobs?.forEach((job) => this.#schedule(job)); + get(snapId: SnapId): InternalBackgroundEvent[] { + return Object.values(this.state.events) + .filter( + (snapEvent) => snapEvent.snapId === snapId && !snapEvent.recurring, + ) + .map((event) => ({ + ...event, + date: toCensoredISO8601String(event.date), + scheduledAt: toCensoredISO8601String(event.scheduledAt), + })); } /** - * Schedule a new job. - * This will interpret the cron expression and tell the timer to execute the job - * at the next suitable point in time. - * Job last run state will be initialized afterwards. + * Register cronjobs for a given Snap by getting specification from the + * permission caveats. Once registered, each job will be scheduled. * - * Note: Schedule will be skipped if the job's execution time is too far in the future and - * will be revisited on a daily check. + * @param snapId - The snap ID to register jobs for. + */ + register(snapId: SnapId) { + const jobs = this.#getSnapCronjobs(snapId); + jobs?.forEach((job) => this.#add(job)); + } + + /** + * Unregister all cronjobs and background events for a given Snap. * - * @param job - Cronjob specification. + * @param snapId - ID of a snap. */ - #schedule(job: Cronjob) { - if (this.#timers.has(job.id)) { - return; + unregister(snapId: SnapId) { + for (const [id, event] of Object.entries(this.state.events)) { + if (event.snapId === snapId) { + this.#cancel(id); + } } + } - const parsed = parseCronExpression(job.expression); - const next = parsed.next(); - const now = new Date(); - const ms = next.getTime() - now.getTime(); + /** + * Run controller teardown process and unsubscribe from Snap events. + */ + destroy() { + super.destroy(); - // Don't schedule this job yet as it is too far in the future - if (ms > DAILY_TIMEOUT) { - return; - } + this.messagingSystem.unsubscribe( + 'SnapController:snapInstalled', + this.#handleSnapInstalledEvent, + ); - const timer = new Timer(ms); - timer.start(() => { - this.#executeCronjob(job).catch((error) => { - // TODO: Decide how to handle errors. - logError(error); - }); + this.messagingSystem.unsubscribe( + 'SnapController:snapUninstalled', + this.#handleSnapUninstalledEvent, + ); - this.#timers.delete(job.id); - this.#schedule(job); - }); + this.messagingSystem.unsubscribe( + 'SnapController:snapEnabled', + this.#handleSnapEnabledEvent, + ); - if (!this.state.jobs[job.id]?.lastRun) { - this.#updateJobLastRunState(job.id, 0); // 0 for init, never ran actually - } + this.messagingSystem.unsubscribe( + 'SnapController:snapDisabled', + this.#handleSnapDisabledEvent, + ); + + this.messagingSystem.unsubscribe( + 'SnapController:snapUpdated', + this.#handleSnapUpdatedEvent, + ); - this.#timers.set(job.id, timer); - this.#snapIds.set(job.id, job.snapId); + // Cancel all timers and clear the map. + this.#timers.forEach((timer) => timer.cancel()); + this.#timers.clear(); + + if (this.#dailyTimer.status === 'running') { + this.#dailyTimer.cancel(); + } } /** - * Execute job. - * - * @param job - Cronjob specification. + * Start the daily timer that will reschedule events every 24 hours. */ - async #executeCronjob(job: Cronjob) { - this.#updateJobLastRunState(job.id, Date.now()); - await this.messagingSystem.call('SnapController:handleRequest', { - snapId: job.snapId, - origin: 'metamask', - handler: HandlerType.OnCronjob, - request: job.request, + #start() { + this.#dailyTimer = new Timer(DAILY_TIMEOUT); + this.#dailyTimer.start(() => { + this.#reschedule(); + this.#start(); }); } /** - * Schedule a background event. + * Add a cronjob or background event to the controller state and schedule it + * for execution. * - * @param backgroundEventWithoutId - Background event. - * @returns An id representing the background event. + * @param event - The event to schedule. + * @returns The ID of the scheduled event. */ - scheduleBackgroundEvent( - backgroundEventWithoutId: Omit, - ) { - // Remove millisecond precision and convert to UTC. - const scheduledAt = DateTime.fromJSDate(new Date()) - .toUTC() - .startOf('second') - .toISO({ - suppressMilliseconds: true, - }); - - assert(scheduledAt); - - const event = { - ...backgroundEventWithoutId, - id: nanoid(), - scheduledAt, + #add(event: SchedulableBackgroundEvent) { + const id = event.id ?? nanoid(); + const internalEvent: InternalBackgroundEvent = { + ...event, + id, + date: getExecutionDate(event.schedule), + scheduledAt: new Date().toISOString(), }; - this.#setUpBackgroundEvent(event); + this.update((state) => { + state.events[internalEvent.id] = castDraft(internalEvent); + }); - return event.id; + this.#schedule(internalEvent); + return id; } /** - * Cancel a background event. + * Get the next execution date for a given event and start a timer for it. * - * @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. + * @param event - The event to schedule. */ - cancelBackgroundEvent(origin: string, id: string) { - assert( - this.state.events[id], - `A background event with the id of "${id}" does not exist.`, - ); - - assert( - this.state.events[id].snapId === origin, - 'Only the origin that scheduled this event can cancel it.', - ); - - const timer = this.#timers.get(id); - timer?.cancel(); - this.#timers.delete(id); - this.#snapIds.delete(id); + #schedule(event: InternalBackgroundEvent) { + const date = getExecutionDate(event.schedule); this.update((state) => { - delete state.events[id]; + state.events[event.id].date = date; + }); + + this.#startTimer({ + ...event, + date, }); } /** - * A helper function to handle setup of the background event. + * Set up and start a timer for the given event. * - * @param event - A background event. + * @param event - The event to schedule. + * @throws If the event is scheduled in the past. */ - #setUpBackgroundEvent(event: BackgroundEvent) { - const date = new Date(event.date); - const now = new Date(); - const ms = date.getTime() - now.getTime(); + #startTimer(event: InternalBackgroundEvent) { + const ms = + DateTime.fromISO(event.date, { setZone: true }).toMillis() - Date.now(); - if (ms <= 0) { - throw new Error('Cannot schedule an event in the past.'); - } - - // The event may already be in state when we get here. - if (!hasProperty(this.state.events, event.id)) { - this.update((state) => { - state.events[event.id] = castDraft(event); - }); + // We don't schedule this job yet as it is too far in the future. + if (ms > DAILY_TIMEOUT) { + return; } const timer = new Timer(ms); timer.start(() => { - this.messagingSystem - .call('SnapController:handleRequest', { - snapId: event.snapId, - origin: 'metamask', - handler: HandlerType.OnCronjob, - request: event.request, - }) - .catch((error) => { - logError(error); - }); - - this.#timers.delete(event.id); - this.#snapIds.delete(event.id); - this.update((state) => { - delete state.events[event.id]; - }); + this.#execute(event); }); this.#timers.set(event.id, timer); - this.#snapIds.set(event.id, event.snapId); } /** - * Get a list of a Snap's background events. + * Execute a background event. This method is called when the event's timer + * expires. * - * @param snapId - The id of the Snap to fetch background events for. - * @returns An array of background events. - */ - getBackgroundEvents(snapId: SnapId): BackgroundEvent[] { - return Object.values(this.state.events) - .filter((snapEvent) => snapEvent.snapId === snapId) - .map((event) => ({ - ...event, - // Truncate dates to remove milliseconds. - date: toCensoredISO8601String(event.date), - })); - } - - /** - * Unregister all jobs and background events related to the given snapId. + * If the event is not recurring, it will be removed from the state after + * execution. If it is recurring, it will be rescheduled. * - * @param snapId - ID of a snap. - * @param skipEvents - Whether the unregistration process should skip scheduled background events. + * @param event - The event to execute. */ - unregister(snapId: SnapId, skipEvents = false) { - const jobs = [...this.#snapIds.entries()].filter( - ([_, jobSnapId]) => jobSnapId === snapId, - ); + #execute(event: InternalBackgroundEvent) { + this.messagingSystem + .call('SnapController:handleRequest', { + snapId: event.snapId, + origin: METAMASK_ORIGIN, + handler: HandlerType.OnCronjob, + request: event.request, + }) + .catch((error) => { + logError( + `An error occurred while executing an event for Snap "${event.snapId}":`, + error, + ); + }); - if (jobs.length) { - const eventIds: string[] = []; - jobs.forEach(([id]) => { - const timer = this.#timers.get(id); - if (timer) { - timer.cancel(); - this.#timers.delete(id); - this.#snapIds.delete(id); - if (!skipEvents && this.state.events[id]) { - eventIds.push(id); - } - } + this.#timers.delete(event.id); + + // Non-recurring events are removed from the state after execution, and + // recurring events are rescheduled. + if (!event.recurring) { + this.update((state) => { + delete state.events[event.id]; }); - if (eventIds.length > 0) { - this.update((state) => { - eventIds.forEach((id) => { - delete state.events[id]; - }); - }); - } + return; } - } - /** - * Update time of a last run for the Cronjob specified by ID. - * - * @param jobId - ID of a cron job. - * @param lastRun - Unix timestamp when the job was last ran. - */ - #updateJobLastRunState(jobId: string, lastRun: number) { - this.update((state) => { - state.jobs[jobId] = { - lastRun, - }; - }); + this.#schedule(event); } /** - * Runs every 24 hours to check if new jobs need to be scheduled. + * Cancel a background event by its ID. Unlike {@link cancel}, this method + * does not check the origin of the event, so it can be used internally. * - * This is necessary for longer running jobs that execute with more than 24 hours between them. + * @param id - The ID of the background event to cancel. */ - dailyCheckIn() { - const jobs = this.#getAllJobs(); - - for (const job of jobs) { - const parsed = parseCronExpression(job.expression); - const lastRun = this.state.jobs[job.id]?.lastRun; - // If a job was supposed to run while we were shut down but wasn't we run it now - if ( - lastRun !== undefined && - parsed.hasPrev() && - parsed.prev().getTime() > lastRun - ) { - this.#executeCronjob(job).catch((error) => { - logError(error); - }); - } - - // Try scheduling, will fail if an existing scheduled job is found - this.#schedule(job); - } + #cancel(id: string) { + const timer = this.#timers.get(id); + timer?.cancel(); + this.#timers.delete(id); - this.#dailyTimer = new Timer(DAILY_TIMEOUT); - this.#dailyTimer.start(() => { - this.dailyCheckIn(); + this.update((state) => { + delete state.events[id]; }); } /** - * Reschedule background events. + * Retrieve all cronjob specifications for a Snap. * - * @param backgroundEvents - A list of background events to reschdule. - */ - #rescheduleBackgroundEvents(backgroundEvents: BackgroundEvent[]) { - for (const snapEvent of backgroundEvents) { - const { date } = snapEvent; - const now = new Date(); - const then = new Date(date); - if (then.getTime() < now.getTime()) { - // Remove expired events from state - this.update((state) => { - delete state.events[snapEvent.id]; - }); - - logWarning( - `Background event with id "${snapEvent.id}" not scheduled as its date has expired.`, - ); - } else { - this.#setUpBackgroundEvent(snapEvent); - } - } - } - - /** - * Run controller teardown process and unsubscribe from Snap events. + * @param snapId - ID of a Snap. + * @returns Array of cronjob specifications. */ - destroy() { - super.destroy(); - - /* eslint-disable @typescript-eslint/unbound-method */ - this.messagingSystem.unsubscribe( - 'SnapController:snapInstalled', - this._handleSnapRegisterEvent, - ); - - this.messagingSystem.unsubscribe( - 'SnapController:snapUninstalled', - this._handleSnapUnregisterEvent, - ); - - this.messagingSystem.unsubscribe( - 'SnapController:snapEnabled', - this._handleSnapEnabledEvent, + #getSnapCronjobs(snapId: SnapId): SchedulableBackgroundEvent[] { + const permissions = this.messagingSystem.call( + 'PermissionController:getPermissions', + snapId, ); - this.messagingSystem.unsubscribe( - 'SnapController:snapDisabled', - this._handleSnapDisabledEvent, - ); + const permission = permissions?.[SnapEndowments.Cronjob]; + const definitions = getCronjobCaveatJobs(permission); - this.messagingSystem.unsubscribe( - 'SnapController:snapUpdated', - this._handleEventSnapUpdated, - ); - /* eslint-enable @typescript-eslint/unbound-method */ + if (!definitions) { + return []; + } - this.#snapIds.forEach((snapId) => this.unregister(snapId)); + return definitions.map((definition, idx) => { + return { + snapId, + id: `cronjob-${snapId}-${idx}`, + request: definition.request, + schedule: getCronjobSpecificationSchedule(definition), + recurring: true, + }; + }); } /** - * Handle events that should cause cronjobs to be registered. + * Handle events that should cause cron jobs to be registered. * * @param snap - Basic Snap information. */ - // TODO: Either fix this lint violation or explain why it's necessary to - // ignore. - // eslint-disable-next-line no-restricted-syntax - private _handleSnapRegisterEvent(snap: TruncatedSnap) { + readonly #handleSnapInstalledEvent = (snap: TruncatedSnap) => { this.register(snap.id); - } + }; /** - * Handle events that could cause cronjobs to be registered - * and for background events to be rescheduled. + * Handle the Snap enabled event. This checks if the Snap has any cronjobs or + * background events that need to be rescheduled. * * @param snap - Basic Snap information. */ - // TODO: Either fix this lint violation or explain why it's necessary to - // ignore. - // eslint-disable-next-line no-restricted-syntax - private _handleSnapEnabledEvent(snap: TruncatedSnap) { - const events = this.getBackgroundEvents(snap.id); - this.#rescheduleBackgroundEvents(events); + readonly #handleSnapEnabledEvent = (snap: TruncatedSnap) => { + const events = this.get(snap.id); + this.#reschedule(events); this.register(snap.id); - } + }; /** - * Handle events that should cause cronjobs and background events to be unregistered. + * Handle events that should cause cronjobs and background events to be + * unregistered. * * @param snap - Basic Snap information. */ - // TODO: Either fix this lint violation or explain why it's necessary to - // ignore. - // eslint-disable-next-line no-restricted-syntax - private _handleSnapUnregisterEvent(snap: TruncatedSnap) { + readonly #handleSnapUninstalledEvent = (snap: TruncatedSnap) => { this.unregister(snap.id); - } + }; /** - * Handle events that should cause cronjobs and background events to be unregistered. + * Handle events that should cause cronjobs and background events to be + * unregistered. * * @param snap - Basic Snap information. */ - // TODO: Either fix this lint violation or explain why it's necessary to - // ignore. - // eslint-disable-next-line no-restricted-syntax - private _handleSnapDisabledEvent(snap: TruncatedSnap) { - this.unregister(snap.id, true); - } + readonly #handleSnapDisabledEvent = (snap: TruncatedSnap) => { + this.unregister(snap.id); + }; /** * Handle cron jobs on 'snapUpdated' event. * * @param snap - Basic Snap information. */ - // TODO: Either fix this lint violation or explain why it's necessary to - // ignore. - // eslint-disable-next-line no-restricted-syntax - private _handleEventSnapUpdated(snap: TruncatedSnap) { + readonly #handleSnapUpdatedEvent = (snap: TruncatedSnap) => { this.unregister(snap.id); this.register(snap.id); + }; + + /** + * Reschedule events that are yet to be executed. This should be called on + * controller initialization and once every 24 hours to ensure that + * background events are scheduled correctly. + * + * @param events - An array of events to reschedule. Defaults to all events in + * the controller state. + */ + #reschedule(events = Object.values(this.state.events)) { + const now = Date.now(); + + for (const event of events) { + if (this.#timers.has(event.id)) { + // If the timer for this event already exists, we don't need to + // reschedule it. + continue; + } + + const eventDate = DateTime.fromISO(event.date, { + setZone: true, + }) + .toUTC() + .toMillis(); + + // If the event is recurring and the date is in the past, execute it + // immediately. + if (event.recurring && eventDate <= now) { + this.#execute(event); + } + + this.#schedule(event); + } + } + + /** + * Clear non-recurring events that are past their scheduled time. + */ + #clear() { + const now = Date.now(); + + for (const event of Object.values(this.state.events)) { + const eventDate = DateTime.fromISO(event.date, { + setZone: true, + }) + .toUTC() + .toMillis(); + + if (!event.recurring && eventDate < now) { + this.#cancel(event.id); + } + } } } diff --git a/packages/snaps-controllers/src/cronjob/utils.test.ts b/packages/snaps-controllers/src/cronjob/utils.test.ts new file mode 100644 index 0000000000..ad7e149d96 --- /dev/null +++ b/packages/snaps-controllers/src/cronjob/utils.test.ts @@ -0,0 +1,76 @@ +import { getCronjobSpecificationSchedule, getExecutionDate } from './utils'; + +jest.useFakeTimers(); +jest.setSystemTime(1747994147500); + +describe('getCronjobSpecificationSchedule', () => { + it('returns the duration if specified', () => { + const specification = { + duration: 'PT1H', + request: { method: 'foo' }, + }; + + expect(getCronjobSpecificationSchedule(specification)).toBe('PT1H'); + }); + + it('returns the expression if no duration is specified', () => { + const specification = { + expression: '0 0 * * *', + request: { method: 'foo' }, + }; + + expect(getCronjobSpecificationSchedule(specification)).toBe('0 0 * * *'); + }); +}); + +describe('getExecutionDate', () => { + it('parses an ISO 8601 date', () => { + expect(getExecutionDate('2025-05-24T09:55:47Z')).toBe( + '2025-05-24T09:55:47Z', + ); + expect(getExecutionDate('2025-05-24T09:55:47+00:00')).toBe( + '2025-05-24T09:55:47Z', + ); + expect(getExecutionDate('2025-05-24T09:55:47+01:00')).toBe( + '2025-05-24T08:55:47Z', + ); + }); + + it('parses an ISO 8601 duration', () => { + expect(getExecutionDate('P1Y')).toBe('2026-05-23T09:55:47.500Z'); + expect(getExecutionDate('PT1S')).toBe('2025-05-23T09:55:48.500Z'); + expect(getExecutionDate('PT0S')).toBe('2025-05-23T09:55:48.500Z'); + }); + + it('parses a cron expression', () => { + expect(getExecutionDate('0 0 * * *')).toBe('2025-05-24T00:00:00.000Z'); + expect(getExecutionDate('0 0 1 * *')).toBe('2025-06-01T00:00:00.000Z'); + expect(getExecutionDate('0 0 1 1 *')).toBe('2026-01-01T00:00:00.000Z'); + expect(getExecutionDate('0 0 1 1 mon')).toBe('2026-01-01T00:00:00.000Z'); + }); + + it('throws an error for invalid input', () => { + expect(() => getExecutionDate('invalid')).toThrow( + 'Unable to parse "invalid" as ISO 8601 date, ISO 8601 duration, or cron expression.', + ); + expect(() => getExecutionDate('2025-05-23T09:55:47Z+01:00')).toThrow( + 'Unable to parse "2025-05-23T09:55:47Z+01:00" as ISO 8601 date, ISO 8601 duration, or cron expression.', + ); + expect(() => getExecutionDate('P1Y2M3D4H')).toThrow( + 'Unable to parse "P1Y2M3D4H" as ISO 8601 date, ISO 8601 duration, or cron expression.', + ); + expect(() => getExecutionDate('100 * * * * *')).toThrow( + 'Unable to parse "100 * * * * *" as ISO 8601 date, ISO 8601 duration, or cron expression.', + ); + }); + + it('throws an error for dates in the past', () => { + expect(() => getExecutionDate('2020-01-01T00:00:00Z')).toThrow( + 'Cannot schedule an event in the past.', + ); + + expect(() => + getExecutionDate(new Date(Date.now() + 100).toISOString()), + ).toThrow('Cannot schedule an event in the past.'); + }); +}); diff --git a/packages/snaps-controllers/src/cronjob/utils.ts b/packages/snaps-controllers/src/cronjob/utils.ts new file mode 100644 index 0000000000..1d61fa7f9c --- /dev/null +++ b/packages/snaps-controllers/src/cronjob/utils.ts @@ -0,0 +1,87 @@ +import type { CronjobSpecification } from '@metamask/snaps-utils'; +import { assert, hasProperty } from '@metamask/utils'; +import { parseExpression } from 'cron-parser'; +import { DateTime, Duration } from 'luxon'; + +/** + * Get the schedule from a cronjob specification. + * + * This function assumes the cronjob specification is valid and contains + * either a `duration` or an `expression` property. + * + * @param specification - The cronjob specification to extract the schedule + * from. + * @returns The schedule of the cronjob, which can be either an ISO 8601 + * duration or a cron expression. + */ +export function getCronjobSpecificationSchedule( + specification: CronjobSpecification, +) { + if (hasProperty(specification, 'duration')) { + return specification.duration as string; + } + + return specification.expression; +} + +/** + * Get a duration with a minimum of 1 second. This function assumes the provided + * duration is valid. + * + * @param duration - The duration to validate. + * @returns The validated duration. + */ +function getDuration(duration: Duration): Duration { + if (duration.as('seconds') < 1) { + return Duration.fromObject({ seconds: 1 }); + } + + return duration; +} + +/** + * Get the next execution date from a schedule, which should be either: + * + * - An ISO 8601 date string, or + * - An ISO 8601 duration string, or + * - A cron expression. + * + * @param schedule - The schedule of the event. + * @returns The parsed ISO 8601 date at which the event should be executed. + */ +export function getExecutionDate(schedule: string) { + const date = DateTime.fromISO(schedule, { setZone: true }); + if (date.isValid) { + const now = Date.now(); + + // We round to the nearest second to avoid milliseconds in the output. + const roundedDate = date.toUTC().startOf('second'); + if (roundedDate.toMillis() < now) { + throw new Error('Cannot schedule an event in the past.'); + } + + return roundedDate.toISO({ + suppressMilliseconds: true, + }); + } + + const duration = Duration.fromISO(schedule); + if (duration.isValid) { + // This ensures the duration is at least 1 second. + const validatedDuration = getDuration(duration); + return DateTime.now().toUTC().plus(validatedDuration).toISO(); + } + + try { + const parsed = parseExpression(schedule, { utc: true }); + const next = parsed.next(); + const nextDate = DateTime.fromJSDate(next.toDate()); + assert(nextDate.isValid); + + return nextDate.toUTC().toISO(); + } catch { + throw new Error( + `Unable to parse "${schedule}" as ISO 8601 date, ISO 8601 duration, or cron expression.`, + ); + } +} diff --git a/packages/snaps-controllers/src/test-utils/cronjob.ts b/packages/snaps-controllers/src/test-utils/cronjob.ts index 15c61c8dc9..d0cc626559 100644 --- a/packages/snaps-controllers/src/test-utils/cronjob.ts +++ b/packages/snaps-controllers/src/test-utils/cronjob.ts @@ -26,20 +26,23 @@ export const MOCK_CRONJOB_PERMISSION: PermissionConstraint = { parentCapability: SnapEndowments.Cronjob, }; -type GetCronjobPermissionArgs = { - expression?: string; -}; +type GetCronjobPermissionArgs = + | { + expression?: string; + } + | { + duration?: string; + }; /** * Get a cronjob permission object as {@link PermissionConstraint}. * * @param args - The arguments to use when creating the permission. - * @param args.expression - The cron expression to use for the permission. * @returns The permission object. */ -export function getCronjobPermission({ - expression = '59 6 * * *', -}: GetCronjobPermissionArgs = {}): PermissionConstraint { +export function getCronjobPermission( + args: GetCronjobPermissionArgs, +): PermissionConstraint { return { caveats: [ { @@ -47,7 +50,7 @@ export function getCronjobPermission({ value: { jobs: [ { - expression, + ...args, request: { method: 'exampleMethod', params: ['p1'], diff --git a/packages/snaps-rpc-methods/jest.config.js b/packages/snaps-rpc-methods/jest.config.js index 399f895c89..82fa12ceb7 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: 95, + branches: 94.96, functions: 98.64, lines: 98.78, - statements: 98.46, + statements: 98.45, }, }, }); diff --git a/packages/snaps-rpc-methods/package.json b/packages/snaps-rpc-methods/package.json index 61dc6002e2..f9d02960e9 100644 --- a/packages/snaps-rpc-methods/package.json +++ b/packages/snaps-rpc-methods/package.json @@ -62,8 +62,7 @@ "@metamask/snaps-utils": "workspace:^", "@metamask/superstruct": "^3.2.1", "@metamask/utils": "^11.4.0", - "@noble/hashes": "^1.7.1", - "luxon": "^3.5.0" + "@noble/hashes": "^1.7.1" }, "devDependencies": { "@lavamoat/allow-scripts": "^3.3.3", @@ -72,7 +71,6 @@ "@swc/core": "1.11.31", "@swc/jest": "^0.2.38", "@ts-bridge/cli": "^0.6.1", - "@types/luxon": "^3", "@types/node": "18.14.2", "deepmerge": "^4.2.2", "depcheck": "^1.4.7", diff --git a/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.test.ts b/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.test.ts index c81eaaccf5..2a95b90d96 100644 --- a/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.test.ts +++ b/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.test.ts @@ -83,7 +83,7 @@ describe('snap_scheduleBackgroundEvent', () => { expect(response).toStrictEqual({ jsonrpc: '2.0', id: 1, result: 'foo' }); }); - it('schedules a background event with second precision', async () => { + it('schedules a background event', async () => { const { implementation } = scheduleBackgroundEventHandler; const scheduleBackgroundEvent = jest.fn(); @@ -123,7 +123,7 @@ describe('snap_scheduleBackgroundEvent', () => { }); expect(scheduleBackgroundEvent).toHaveBeenCalledWith({ - date: '2022-01-01T01:00:35+02:00', + schedule: '2022-01-01T01:00:35.786+02:00', request: { method: 'handleExport', params: ['p1'], @@ -131,7 +131,7 @@ describe('snap_scheduleBackgroundEvent', () => { }); }); - it('schedules a background event using duration', async () => { + it('schedules a background event using a duration', async () => { const { implementation } = scheduleBackgroundEventHandler; const scheduleBackgroundEvent = jest.fn(); @@ -171,55 +171,7 @@ describe('snap_scheduleBackgroundEvent', () => { }); expect(scheduleBackgroundEvent).toHaveBeenCalledWith({ - date: '2025-05-21T13:25:21.500Z', - request: { - method: 'handleExport', - params: ['p1'], - }, - }); - }); - - it('schedules a background event using a minimum duration of 1 second', async () => { - const { implementation } = scheduleBackgroundEventHandler; - - const scheduleBackgroundEvent = jest.fn(); - const hasPermission = jest.fn().mockImplementation(() => true); - - const hooks = { - scheduleBackgroundEvent, - hasPermission, - }; - - const engine = new JsonRpcEngine(); - - engine.push(createOriginMiddleware(MOCK_SNAP_ID)); - engine.push((request, response, next, end) => { - const result = implementation( - request as JsonRpcRequest, - response as PendingJsonRpcResponse, - next, - end, - hooks, - ); - - result?.catch(end); - }); - - await engine.handle({ - jsonrpc: '2.0', - id: 1, - method: 'snap_scheduleBackgroundEvent', - params: { - duration: 'PT0.5S', - request: { - method: 'handleExport', - params: ['p1'], - }, - }, - }); - - expect(scheduleBackgroundEvent).toHaveBeenCalledWith({ - date: '2025-05-21T13:25:21.500Z', + schedule: 'PT1S', 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 248e94e94a..498d93dc42 100644 --- a/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.ts +++ b/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.ts @@ -12,15 +12,9 @@ import { CronjobRpcRequestStruct, ISO8601DateStruct, ISO8601DurationStruct, - toCensoredISO8601String, } from '@metamask/snaps-utils'; import { StructError, create, object } from '@metamask/superstruct'; -import { - assert, - hasProperty, - type PendingJsonRpcResponse, -} from '@metamask/utils'; -import { DateTime, Duration } from 'luxon'; +import { hasProperty, type PendingJsonRpcResponse } from '@metamask/utils'; import { SnapEndowments } from '../endowments'; import type { MethodHooksObject } from '../utils'; @@ -33,7 +27,7 @@ const hookNames: MethodHooksObject = { }; type ScheduleBackgroundEventHookParams = { - date: string; + schedule: string; request: CronjobRpcRequest; }; @@ -78,24 +72,18 @@ export type ScheduleBackgroundEventParameters = InferMatching< >; /** - * Generates a `DateTime` object based on if a duration or date is provided. + * Get the schedule for a background event based on the provided parameters. * - * @param params - The validated params from the `snap_scheduleBackgroundEvent` call. - * @returns A `DateTime` object. + * @param params - The parameters for the background event. + * @returns The schedule parameters for the background event. */ -function getStartDate(params: ScheduleBackgroundEventParams) { - if ('duration' in params) { - const parsed = Duration.fromISO(params.duration); - - // Disallow durations less than 1 second. - const duration = - parsed.as('seconds') >= 1 ? parsed : Duration.fromObject({ seconds: 1 }); - - return DateTime.fromJSDate(new Date()).toUTC().plus(duration).toISO(); +function getSchedule(params: ScheduleBackgroundEventParameters): string { + if (hasProperty(params, 'date')) { + // TODO: Check why `params.date` is not a string. + return params.date as string; } - // Make sure any millisecond precision is removed. - return toCensoredISO8601String(params.date); + return params.duration; } /** @@ -129,14 +117,14 @@ async function getScheduleBackgroundEventImplementation( try { const validatedParams = getValidatedParams(params); - const { request } = validatedParams; + const schedule = getSchedule(validatedParams); - const date = getStartDate(validatedParams); - - assert(date); + const id = scheduleBackgroundEvent({ + schedule, + request, + }); - 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 b2f322c038..77014ba819 100644 --- a/packages/snaps-sdk/src/types/methods/get-background-events.ts +++ b/packages/snaps-sdk/src/types/methods/get-background-events.ts @@ -3,15 +3,18 @@ import type { Json } from '@metamask/utils'; import type { SnapId } from '../snap'; /** - * Background event type + * Background event type. * - * Note: The date generated when scheduling an event with a duration will be represented in UTC. + * Note: The date generated when scheduling an event with a duration will be + * represented in UTC. * * @property id - The unique id representing the event. - * @property scheduledAt - The ISO 8601 time stamp of when the event was scheduled. + * @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. + * @property request - The request that is supplied to the `onCronjob` handler + * when the event is fired. */ export type BackgroundEvent = { id: string; diff --git a/packages/snaps-sdk/src/types/permissions.ts b/packages/snaps-sdk/src/types/permissions.ts index 2bf1b60113..1427b6bce3 100644 --- a/packages/snaps-sdk/src/types/permissions.ts +++ b/packages/snaps-sdk/src/types/permissions.ts @@ -3,11 +3,20 @@ import type { CaipChainId, JsonRpcRequest } from '@metamask/utils'; export type EmptyObject = Record; -export type Cronjob = { - expression: string; +type CronjobRequest = { request: Omit; }; +type CronjobWithExpression = CronjobRequest & { + expression: string; +}; + +type CronjobWithDuration = CronjobRequest & { + duration: string; +}; + +export type Cronjob = CronjobWithExpression | CronjobWithDuration; + export type NameLookupMatchers = | { tlds: string[]; diff --git a/packages/snaps-utils/coverage.json b/packages/snaps-utils/coverage.json index 4d476822af..128a3f13dd 100644 --- a/packages/snaps-utils/coverage.json +++ b/packages/snaps-utils/coverage.json @@ -2,5 +2,5 @@ "branches": 99.76, "functions": 99, "lines": 98.68, - "statements": 97.2 + "statements": 97.27 } diff --git a/packages/snaps-utils/src/cronjob.test.ts b/packages/snaps-utils/src/cronjob.test.ts index c7ba2d0605..26e19e94e8 100644 --- a/packages/snaps-utils/src/cronjob.test.ts +++ b/packages/snaps-utils/src/cronjob.test.ts @@ -1,69 +1,113 @@ +import { create } from '@metamask/superstruct'; + import { + CronjobSpecificationStruct, isCronjobSpecification, isCronjobSpecificationArray, - parseCronExpression, } from './cronjob'; -describe('Cronjob Utilities', () => { - describe('isCronjobSpecification', () => { - it('returns true for a valid cronjob specification', () => { - const cronjobSpecification = { - expression: '* * * * *', +describe('CronjobSpecificationStruct', () => { + it.each([ + ['100 * * * * *', 'Expected an object, but received: "100 * * * * *"'], + ['PT10S', 'Expected an object, but received: "PT10S"'], + [ + { + expression: '100 * * * * *', request: { method: 'exampleMethodOne', params: ['p1'], }, - }; - expect(isCronjobSpecification(cronjobSpecification)).toBe(true); - }); - - it('returns false for an invalid cronjob specification', () => { - const cronjobSpecification = { - expression: '* * * * * * * * * * *', + }, + 'At path: expression -- Expected a cronjob expression, but received: "100 * * * * *"', + ], + [ + { + duration: '10S', + request: { + method: 'exampleMethodOne', + params: ['p1'], + }, + }, + 'At path: duration -- Not a valid ISO 8601 duration', + ], + [ + { request: { method: 'exampleMethodOne', params: ['p1'], }, - }; - expect(isCronjobSpecification(cronjobSpecification)).toBe(false); - }); + }, + 'At path: expression -- Expected a string, but received: undefined', + ], + ])('return a readable error for %p', (value, error) => { + expect(() => create(value, CronjobSpecificationStruct)).toThrow(error); }); +}); - describe('isCronjobSpecificationArray', () => { - it('returns true for a valid cronjob specification array', () => { - const cronjobSpecificationArray = [ - { - expression: '* * * * *', - request: { - method: 'exampleMethodOne', - params: ['p1'], - }, - }, - ]; - expect(isCronjobSpecificationArray(cronjobSpecificationArray)).toBe(true); - }); +describe('isCronjobSpecification', () => { + it('returns true for a valid cronjob specification with an expression', () => { + const cronjobSpecification = { + expression: '* * * * *', + request: { + method: 'exampleMethodOne', + params: ['p1'], + }, + }; + expect(isCronjobSpecification(cronjobSpecification)).toBe(true); + }); - it('returns false for an invalid cronjob specification array', () => { - const cronjobSpecificationArray = { + it('returns true for a valid cronjob specification with a duration', () => { + const cronjobSpecification = { + duration: 'PT10S', + request: { + method: 'exampleMethodOne', + params: ['p1'], + }, + }; + expect(isCronjobSpecification(cronjobSpecification)).toBe(true); + }); + + it('returns false for an invalid cronjob specification', () => { + const cronjobSpecification = { + expression: '* * * * * * * * * * *', + request: { + method: 'exampleMethodOne', + params: ['p1'], + }, + }; + expect(isCronjobSpecification(cronjobSpecification)).toBe(false); + }); +}); + +describe('isCronjobSpecificationArray', () => { + it('returns true for a valid cronjob specification array', () => { + const cronjobSpecificationArray = [ + { expression: '* * * * *', request: { method: 'exampleMethodOne', params: ['p1'], }, - }; - expect(isCronjobSpecificationArray(cronjobSpecificationArray)).toBe( - false, - ); - }); + }, + { + duration: 'PT10S', + request: { + method: 'exampleMethodOne', + params: ['p1'], + }, + }, + ]; + expect(isCronjobSpecificationArray(cronjobSpecificationArray)).toBe(true); }); - describe('parseCronExpression', () => { - it('successfully parses cronjob expression that is provided as a string', () => { - const cronjobExpression = '* * * * *'; - - const parsedExpression = parseCronExpression(cronjobExpression); - expect(parsedExpression.next()).toBeDefined(); - expect(typeof parsedExpression.next().getTime()).toBe('number'); - }); + it('returns false for an invalid cronjob specification array', () => { + const cronjobSpecificationArray = { + expression: '* * * * *', + request: { + method: 'exampleMethodOne', + params: ['p1'], + }, + }; + expect(isCronjobSpecificationArray(cronjobSpecificationArray)).toBe(false); }); }); diff --git a/packages/snaps-utils/src/cronjob.ts b/packages/snaps-utils/src/cronjob.ts index 694ce762c0..35df3bbb35 100644 --- a/packages/snaps-utils/src/cronjob.ts +++ b/packages/snaps-utils/src/cronjob.ts @@ -1,3 +1,4 @@ +import { selectiveUnion } from '@metamask/snaps-sdk'; import type { Infer } from '@metamask/superstruct'; import { array, @@ -8,12 +9,16 @@ import { string, } from '@metamask/superstruct'; import { + hasProperty, + isObject, JsonRpcIdStruct, JsonRpcParamsStruct, JsonRpcVersionStruct, } from '@metamask/utils'; import { parseExpression } from 'cron-parser'; +import { ISO8601DurationStruct } from './time'; + export const CronjobRpcRequestStruct = object({ jsonrpc: optional(JsonRpcVersionStruct), id: optional(JsonRpcIdStruct), @@ -25,34 +30,37 @@ export type CronjobRpcRequest = Infer; export const CronExpressionStruct = refine( string(), - 'CronExpression', + 'cronjob expression', (value) => { try { parseExpression(value); return true; } catch { - return false; + return `Expected a cronjob expression, but received: "${value}"`; } }, ); export type CronExpression = Infer; -/** - * Parses a cron expression. - * - * @param expression - Expression to parse. - * @returns A CronExpression class instance. - */ -export function parseCronExpression(expression: string | object) { - const ensureStringExpression = create(expression, CronExpressionStruct); - return parseExpression(ensureStringExpression); -} - -export const CronjobSpecificationStruct = object({ +const CronjobExpressionSpecificationStruct = object({ expression: CronExpressionStruct, request: CronjobRpcRequestStruct, }); + +const CronjobDurationSpecificationStruct = object({ + duration: ISO8601DurationStruct, + request: CronjobRpcRequestStruct, +}); + +export const CronjobSpecificationStruct = selectiveUnion((value) => { + if (isObject(value) && hasProperty(value, 'duration')) { + return CronjobDurationSpecificationStruct; + } + + return CronjobExpressionSpecificationStruct; +}); + export type CronjobSpecification = Infer; /** diff --git a/packages/snaps-utils/src/time.ts b/packages/snaps-utils/src/time.ts index 8f5916890e..bafa6d282f 100644 --- a/packages/snaps-utils/src/time.ts +++ b/packages/snaps-utils/src/time.ts @@ -12,6 +12,7 @@ export const ISO8601DurationStruct = refine( if (!parsedDuration.isValid) { return 'Not a valid ISO 8601 duration'; } + return true; }, ); diff --git a/yarn.lock b/yarn.lock index 31db6e99d2..59e813dfde 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4244,6 +4244,7 @@ __metadata: "@xstate/fsm": "npm:^2.0.0" async-mutex: "npm:^0.5.0" concat-stream: "npm:^2.0.0" + cron-parser: "npm:^4.5.0" deepmerge: "npm:^4.2.2" depcheck: "npm:^1.4.7" eslint: "npm:^9.11.0" @@ -4429,7 +4430,6 @@ __metadata: "@swc/core": "npm:1.11.31" "@swc/jest": "npm:^0.2.38" "@ts-bridge/cli": "npm:^0.6.1" - "@types/luxon": "npm:^3" "@types/node": "npm:18.14.2" deepmerge: "npm:^4.2.2" depcheck: "npm:^1.4.7" @@ -4437,7 +4437,6 @@ __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:^3.3.3" typescript: "npm:~5.3.3" languageName: unknown