Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions packages/snaps-rpc-methods/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
},
});
Original file line number Diff line number Diff line change
Expand Up @@ -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<ScheduleBackgroundEventParams>,
response as PendingJsonRpcResponse<ScheduleBackgroundEventResult>,
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<ScheduleBackgroundEventParams>,
response as PendingJsonRpcResponse<ScheduleBackgroundEventResult>,
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;

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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';
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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);

Expand All @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading