Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add logic to allow ISO 8601 duration strings #2975

Merged
merged 10 commits into from
Jan 7, 2025
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) => {
hmalik88 marked this conversation as resolved.
Show resolved Hide resolved
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.
hmalik88 marked this conversation as resolved.
Show resolved Hide resolved
*
* @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