diff --git a/packages/controllers/package.json b/packages/controllers/package.json index a68fa9e989..b5bf118e25 100644 --- a/packages/controllers/package.json +++ b/packages/controllers/package.json @@ -42,6 +42,7 @@ "@metamask/utils": "^2.0.0", "@types/deep-freeze-strict": "^1.1.0", "concat-stream": "^2.0.0", + "cron-parser": "^4.5.0", "deep-freeze-strict": "^1.1.1", "eth-rpc-errors": "^4.0.2", "gunzip-maybe": "^1.4.2", diff --git a/packages/controllers/src/services/CronjobService.ts b/packages/controllers/src/services/CronjobService.ts new file mode 100644 index 0000000000..d588e4648a --- /dev/null +++ b/packages/controllers/src/services/CronjobService.ts @@ -0,0 +1,125 @@ +import { + RestrictedControllerMessenger, + HasPermission, + GetPermissions, +} from '@metamask/controllers'; +import { SnapId } from '@metamask/snap-types'; +import { parseExpression } from 'cron-parser'; +import { nanoid } from 'nanoid'; +import { + GetSnap, + HandleSnapCronjobRequest, + SnapAdded, + SnapEndowments, +} from '..'; +import { Timer } from '../snaps/Timer'; + +export type CronjobServiceActions = + | GetSnap + | HandleSnapCronjobRequest + | HasPermission + | GetPermissions; + +export type CronjobServiceEvents = SnapAdded; + +export type CronjobServiceMessenger = RestrictedControllerMessenger< + 'CronjobService', + CronjobServiceActions, + CronjobServiceEvents, + CronjobServiceActions['type'], + CronjobServiceEvents['type'] +>; + +export type CronjobServiceArgs = { + messenger: CronjobServiceMessenger; +}; + +export type CronjobData = { + jobs: Cronjob[]; +}; + +export type CronjobDefinition = { + expression: string; + request: Record; +}; + +export type Cronjob = { + timer?: Timer; + id: string; +} & CronjobDefinition; + +export class CronjobService { + private _messenger: CronjobServiceMessenger; + + private _snaps: Map; + + private _jobs: Map; + + constructor({ messenger }: CronjobServiceArgs) { + this._snaps = new Map(); + this._jobs = new Map(); + this._messenger = messenger; + } + + async register(snapId: SnapId) { + const hasCronjob = await this._messenger.call( + 'PermissionController:hasPermission', + snapId, + SnapEndowments.cronjob, + ); + if (!hasCronjob) { + return; + } + const permissions = await this._messenger.call( + 'PermissionController:getPermissions', + snapId, + ); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const cronjobPermission = permissions![SnapEndowments.cronjob]!; + // @todo Figure out how to get this from the permission + const definitions: CronjobDefinition[] = (cronjobPermission as any).jobs; + + const jobs = definitions.map((definition) => { + const id = nanoid(); + return { ...definition, id }; + }); + + this._snaps.set( + snapId, + jobs.map((job) => job.id), + ); + + jobs.forEach((job) => this.schedule(snapId, job)); + } + + unregister(snapId: SnapId) { + const jobs = this._snaps.get(snapId); + jobs?.forEach((id) => { + const job = this._jobs.get(id); + job?.timer?.cancel(); + }); + this._snaps.delete(snapId); + } + + schedule(snapId: SnapId, job: Cronjob) { + const parsed = parseExpression(job.expression); + if (!parsed.hasNext()) { + return; + } + const next = parsed.next(); + const now = new Date(); + const ms = now.getTime() - next.getTime(); + const timer = new Timer(ms); + timer.start(() => { + this._messenger.call( + 'SnapController:handleCronjobRequest', + snapId, + // @todo Decide on origin for requests like this + 'METAMASK', + job.request, + ); + this.schedule(snapId, job); + }); + this._jobs.set(job.id, { ...job, timer }); + } +} diff --git a/packages/controllers/src/snaps/SnapController.ts b/packages/controllers/src/snaps/SnapController.ts index 4bd7282c77..fb1a878a29 100644 --- a/packages/controllers/src/snaps/SnapController.ts +++ b/packages/controllers/src/snaps/SnapController.ts @@ -269,6 +269,14 @@ export type HandleSnapRpcRequest = { handler: SnapController['handleRpcRequest']; }; +/** + * Handles sending an inbound cronjob message to a snap and returns its result. + */ +export type HandleSnapCronjobRequest = { + type: `${typeof controllerName}:handleCronjobRequest`; + handler: SnapController['handleCronjobRequest']; +}; + /** * Gets the specified Snap's persisted state. */ @@ -315,6 +323,7 @@ export type SnapControllerActions = | GetSnap | GetSnapState | HandleSnapRpcRequest + | HandleSnapCronjobRequest | HasSnap | UpdateBlockedSnaps | UpdateSnapState; @@ -2083,6 +2092,27 @@ export class SnapController extends BaseController< }); } + /** + * Passes a JSON-RPC request object to the RPC handler function of a snap, triggering the onCronjob handler. + * + * @param snapId - The ID of the recipient snap. + * @param origin - The origin of the RPC request. + * @param request - The JSON-RPC request object. + * @returns The result of the JSON-RPC request. + */ + async handleCronjobRequest( + snapId: SnapId, + origin: string, + request: Record, + ): Promise { + return this.handleRequest({ + snapId, + origin, + handler: HandlerType.onCronjob, + request, + }); + } + /** * Passes a JSON-RPC request object to the RPC handler function of a snap. * diff --git a/packages/controllers/src/snaps/endowments/cronjob.test.ts b/packages/controllers/src/snaps/endowments/cronjob.test.ts new file mode 100644 index 0000000000..afe812565b --- /dev/null +++ b/packages/controllers/src/snaps/endowments/cronjob.test.ts @@ -0,0 +1,18 @@ +import { PermissionType } from '@metamask/controllers'; +import { transactionInsightEndowmentBuilder } from './transaction-insight'; +import { SnapEndowments } from '.'; + +describe('endowment:cronjob', () => { + it('builds the expected permission specification', () => { + const specification = + transactionInsightEndowmentBuilder.specificationBuilder({}); + expect(specification).toStrictEqual({ + permissionType: PermissionType.Endowment, + targetKey: SnapEndowments.cronjob, + endowmentGetter: expect.any(Function), + allowedCaveats: null, + }); + + expect(specification.endowmentGetter()).toBeUndefined(); + }); +}); diff --git a/packages/controllers/src/snaps/endowments/cronjob.ts b/packages/controllers/src/snaps/endowments/cronjob.ts new file mode 100644 index 0000000000..d5bd49cc10 --- /dev/null +++ b/packages/controllers/src/snaps/endowments/cronjob.ts @@ -0,0 +1,41 @@ +import { + PermissionSpecificationBuilder, + PermissionType, + EndowmentGetterParams, + ValidPermissionSpecification, +} from '@metamask/controllers'; +import { SnapEndowments } from './enum'; + +const permissionName = SnapEndowments.cronjob; + +type CronjobEndowmentSpecification = ValidPermissionSpecification<{ + permissionType: PermissionType.Endowment; + targetKey: typeof permissionName; + endowmentGetter: (_options?: any) => undefined; + allowedCaveats: null; +}>; + +/** + * `endowment:cronjob` returns nothing; it is intended to be used as a flag to determine whether the snap wants to run cronjobs. + * + * @param _builderOptions - Optional specification builder options. + * @returns The specification for the cronjob endowment. + */ +const specificationBuilder: PermissionSpecificationBuilder< + PermissionType.Endowment, + any, + CronjobEndowmentSpecification +> = (_builderOptions?: any) => { + return { + permissionType: PermissionType.Endowment, + targetKey: permissionName, + // @todo not allowed by types? + allowedCaveats: ['cronjobCaveat'] as any, + endowmentGetter: (_getterOptions?: EndowmentGetterParams) => undefined, + }; +}; + +export const cronjobEndowmentBuilder = Object.freeze({ + targetKey: permissionName, + specificationBuilder, +} as const); diff --git a/packages/controllers/src/snaps/endowments/enum.ts b/packages/controllers/src/snaps/endowments/enum.ts index 82a4dfda4c..aed6d07b3f 100644 --- a/packages/controllers/src/snaps/endowments/enum.ts +++ b/packages/controllers/src/snaps/endowments/enum.ts @@ -2,4 +2,5 @@ export enum SnapEndowments { networkAccess = 'endowment:network-access', longRunning = 'endowment:long-running', transactionInsight = 'endowment:transaction-insight', + cronjob = 'endowment:cronjob', } diff --git a/packages/controllers/src/snaps/endowments/index.ts b/packages/controllers/src/snaps/endowments/index.ts index 93ac7f99b8..8472b02ddd 100644 --- a/packages/controllers/src/snaps/endowments/index.ts +++ b/packages/controllers/src/snaps/endowments/index.ts @@ -1,3 +1,4 @@ +import { cronjobEndowmentBuilder } from './cronjob'; import { longRunningEndowmentBuilder } from './long-running'; import { networkAccessEndowmentBuilder } from './network-access'; import { transactionInsightEndowmentBuilder } from './transaction-insight'; @@ -7,6 +8,7 @@ export const endowmentPermissionBuilders = { [longRunningEndowmentBuilder.targetKey]: longRunningEndowmentBuilder, [transactionInsightEndowmentBuilder.targetKey]: transactionInsightEndowmentBuilder, + [cronjobEndowmentBuilder.targetKey]: cronjobEndowmentBuilder, } as const; export * from './enum'; diff --git a/packages/execution-environments/src/common/enums.ts b/packages/execution-environments/src/common/enums.ts index e0adeb9f68..5ce56d46ef 100644 --- a/packages/execution-environments/src/common/enums.ts +++ b/packages/execution-environments/src/common/enums.ts @@ -6,4 +6,5 @@ export enum SNAP_STREAM_NAMES { export enum HandlerType { onRpcRequest = 'onRpcRequest', getTransactionInsight = 'getTransactionInsight', + onCronjob = 'onCronjob', } diff --git a/packages/types/src/types.d.ts b/packages/types/src/types.d.ts index d31ee811e5..89ad209ee0 100644 --- a/packages/types/src/types.d.ts +++ b/packages/types/src/types.d.ts @@ -33,6 +33,8 @@ export type GetTransactionInsightHandler = (args: { transaction: { [key: string]: unknown }; }) => Promise; +export type OnCronjobHandler = SnapRpcHandler; + export type SnapProvider = MetaMaskInpageProvider; export type SnapId = string; @@ -46,4 +48,5 @@ export type ErrorJSON = { export type SnapExports = { onRpcRequest?: OnRpcRequestHandler; getTransactionInsight?: GetTransactionInsightHandler; + onCronjob?: OnCronjobHandler; }; diff --git a/yarn.lock b/yarn.lock index 8b09b1edc3..15ed254366 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4929,6 +4929,7 @@ __metadata: "@types/readable-stream": ^2.3.9 "@types/tar-stream": ^2.2.2 concat-stream: ^2.0.0 + cron-parser: ^4.5.0 deep-freeze-strict: ^1.1.1 eslint: ^7.30.0 eslint-config-prettier: ^8.3.0 @@ -9092,6 +9093,15 @@ __metadata: languageName: node linkType: hard +"cron-parser@npm:^4.5.0": + version: 4.5.0 + resolution: "cron-parser@npm:4.5.0" + dependencies: + luxon: ^2.4.0 + checksum: 9e5a6d07c2d86fb27b5701067018776aaf9ad4bf9f57a0f02e5f7c33d3d46dd804802ed74c54f001b18db540293633f1904632efdab3466e1f5630b953de26eb + languageName: node + linkType: hard + "cross-fetch@npm:^2.1.0": version: 2.2.6 resolution: "cross-fetch@npm:2.2.6" @@ -14986,6 +14996,13 @@ __metadata: languageName: node linkType: hard +"luxon@npm:^2.4.0": + version: 2.5.0 + resolution: "luxon@npm:2.5.0" + checksum: 2fccce6bbdfc8f13c5a8c148ff045ab3b10f4f80cac28dd92575588fffce9b2d7197096d7fedcc61a6245b59f4233507797f530e63f22b9ae4c425dff2909ae3 + languageName: node + linkType: hard + "magic-string@npm:0.25.1": version: 0.25.1 resolution: "magic-string@npm:0.25.1"