diff --git a/packages/snaps-rpc-methods/jest.config.js b/packages/snaps-rpc-methods/jest.config.js index fa993ad89b..fafe368501 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.91, - functions: 98.02, - lines: 98.65, - statements: 98.24, + branches: 93.97, + functions: 98.05, + lines: 98.67, + statements: 98.25, }, }, }); diff --git a/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.test.ts b/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.test.ts index a121ef8c1d..09848b9524 100644 --- a/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.test.ts +++ b/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.test.ts @@ -120,6 +120,105 @@ describe('snap_scheduleBackgroundEvent', () => { }); }); + it('schedules a background event using duration', 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: 'PT30S', + request: { + method: 'handleExport', + params: ['p1'], + }, + }, + }); + + expect(scheduleBackgroundEvent).toHaveBeenCalledWith({ + date: expect.any(String), + request: { + method: 'handleExport', + params: ['p1'], + }, + }); + }); + + it('throws on an invalid duration', 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: { + duration: 'PQ30S', + request: { + method: 'handleExport', + params: ['p1'], + }, + }, + }); + + expect(response).toStrictEqual({ + error: { + code: -32602, + message: + 'Invalid params: At path: duration -- Not a valid ISO 8601 duration.', + stack: expect.any(String), + }, + id: 1, + jsonrpc: '2.0', + }); + }); + it('throws if a snap does not have the "endowment:cronjob" permission', async () => { const { implementation } = scheduleBackgroundEventHandler; @@ -171,7 +270,7 @@ describe('snap_scheduleBackgroundEvent', () => { }); }); - it('throws if no timezone information is provided in the ISO8601 string', async () => { + it('throws if no timezone information is provided in the ISO 8601 date', async () => { const { implementation } = scheduleBackgroundEventHandler; const scheduleBackgroundEvent = jest.fn(); @@ -214,7 +313,7 @@ describe('snap_scheduleBackgroundEvent', () => { error: { code: -32602, message: - 'Invalid params: At path: date -- ISO 8601 string must have timezone information.', + 'Invalid params: At path: date -- ISO 8601 date must have timezone information.', stack: expect.any(String), }, id: 1, @@ -265,7 +364,7 @@ describe('snap_scheduleBackgroundEvent', () => { error: { code: -32602, message: - 'Invalid params: At path: date -- Not a valid ISO 8601 string.', + 'Invalid params: At path: date -- Not a valid ISO 8601 date.', 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 99e7ce2813..acdfeebde1 100644 --- a/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.ts +++ b/packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.ts @@ -1,10 +1,11 @@ import type { JsonRpcEngineEndCallback } from '@metamask/json-rpc-engine'; import type { PermittedHandlerExport } from '@metamask/permission-controller'; import { providerErrors, rpcErrors } from '@metamask/rpc-errors'; -import type { - JsonRpcRequest, - ScheduleBackgroundEventParams, - ScheduleBackgroundEventResult, +import { + selectiveUnion, + type JsonRpcRequest, + type ScheduleBackgroundEventParams, + type ScheduleBackgroundEventResult, } from '@metamask/snaps-sdk'; import type { CronjobRpcRequest } from '@metamask/snaps-utils'; import { @@ -18,8 +19,12 @@ import { refine, string, } from '@metamask/superstruct'; -import { assert, type PendingJsonRpcResponse } from '@metamask/utils'; -import { DateTime } from 'luxon'; +import { + assert, + hasProperty, + type PendingJsonRpcResponse, +} from '@metamask/utils'; +import { DateTime, Duration } from 'luxon'; import { SnapEndowments } from '../endowments'; import type { MethodHooksObject } from '../utils'; @@ -55,26 +60,61 @@ export const scheduleBackgroundEventHandler: PermittedHandlerExport< }; const offsetRegex = /Z|([+-]\d{2}:?\d{2})$/u; -const ScheduleBackgroundEventsParametersStruct = object({ + +const ScheduleBackgroundEventParametersWithDateStruct = object({ date: refine(string(), 'date', (val) => { const date = DateTime.fromISO(val); if (date.isValid) { // Luxon doesn't have a reliable way to check if timezone info was not provided if (!offsetRegex.test(val)) { - return 'ISO 8601 string must have timezone information'; + return 'ISO 8601 date must have timezone information'; } return true; } - return 'Not a valid ISO 8601 string'; + return 'Not a valid ISO 8601 date'; + }), + request: CronjobRpcRequestStruct, +}); + +const ScheduleBackgroundEventParametersWithDurationStruct = object({ + duration: refine(string(), 'duration', (val) => { + const duration = Duration.fromISO(val); + if (!duration.isValid) { + return 'Not a valid ISO 8601 duration'; + } + return true; }), request: CronjobRpcRequestStruct, }); +const ScheduleBackgroundEventParametersStruct = selectiveUnion((val) => { + if (hasProperty(val, 'date')) { + return ScheduleBackgroundEventParametersWithDateStruct; + } + return ScheduleBackgroundEventParametersWithDurationStruct; +}); + export type ScheduleBackgroundEventParameters = InferMatching< - typeof ScheduleBackgroundEventsParametersStruct, + typeof ScheduleBackgroundEventParametersStruct, ScheduleBackgroundEventParams >; +/** + * Generates a `DateTime` object based on if a duration or date is provided. + * + * @param params - The validated params from the `snap_scheduleBackgroundEvent` call. + * @returns A `DateTime` object. + */ +function getStartDate(params: ScheduleBackgroundEventParams) { + if ('duration' in params) { + return DateTime.fromJSDate(new Date()) + .toUTC() + .plus(Duration.fromISO(params.duration)); + } + + return DateTime.fromISO(params.date, { setZone: true }); +} + /** * The `snap_scheduleBackgroundEvent` method implementation. * @@ -107,14 +147,14 @@ async function getScheduleBackgroundEventImplementation( try { const validatedParams = getValidatedParams(params); - const { date, request } = validatedParams; + const { request } = validatedParams; + + const date = getStartDate(validatedParams); // Make sure any millisecond precision is removed. - const truncatedDate = DateTime.fromISO(date, { setZone: true }) - .startOf('second') - .toISO({ - suppressMilliseconds: true, - }); + const truncatedDate = date.startOf('second').toISO({ + suppressMilliseconds: true, + }); assert(truncatedDate); @@ -138,7 +178,7 @@ function getValidatedParams( params: unknown, ): ScheduleBackgroundEventParameters { try { - return create(params, ScheduleBackgroundEventsParametersStruct); + return create(params, ScheduleBackgroundEventParametersStruct); } catch (error) { if (error instanceof StructError) { throw rpcErrors.invalidParams({ 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 84fd6dfcff..b2f322c038 100644 --- a/packages/snaps-sdk/src/types/methods/get-background-events.ts +++ b/packages/snaps-sdk/src/types/methods/get-background-events.ts @@ -5,6 +5,8 @@ import type { SnapId } from '../snap'; /** * Background event type * + * 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 snapId - The id of the snap that scheduled the event. 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 caa58e3390..19f121f933 100644 --- a/packages/snaps-sdk/src/types/methods/schedule-background-event.ts +++ b/packages/snaps-sdk/src/types/methods/schedule-background-event.ts @@ -3,13 +3,18 @@ import type { Cronjob } from '../permissions'; /** * The request parameters for the `snap_scheduleBackgroundEvent` method. * - * @property date - The ISO8601 date of when to fire the background event. + * Note: The date generated from a duration will be represented in UTC. + * + * @property date - The ISO 8601 date of when to fire the background event. + * @property duration - The ISO 8601 duration of when to fire the background event. * @property request - The request to be called when the event fires. */ -export type ScheduleBackgroundEventParams = { - date: string; - request: Cronjob['request']; -}; +export type ScheduleBackgroundEventParams = + | { + date: string; + request: Cronjob['request']; + } + | { duration: string; request: Cronjob['request'] }; /** * The result returned by the `snap_scheduleBackgroundEvent` method, which is the ID of the scheduled event.