From 43ce99350d959c985526ed0ca94acb6a57c032a0 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Wed, 23 Oct 2024 00:14:21 -0400 Subject: [PATCH] implement interface persistence --- .../SnapInterfaceController.test.tsx | 124 +++++++++++++++++- .../src/interface/SnapInterfaceController.ts | 60 ++++++++- .../src/snaps/SnapController.ts | 22 +++- .../src/test-utils/controller.ts | 1 + .../src/restricted/dialog.test.tsx | 30 ++++- .../src/restricted/dialog.ts | 19 +++ packages/snaps-sdk/src/types/interface.ts | 7 + 7 files changed, 255 insertions(+), 8 deletions(-) diff --git a/packages/snaps-controllers/src/interface/SnapInterfaceController.test.tsx b/packages/snaps-controllers/src/interface/SnapInterfaceController.test.tsx index a3561840df..a7481d65f3 100644 --- a/packages/snaps-controllers/src/interface/SnapInterfaceController.test.tsx +++ b/packages/snaps-controllers/src/interface/SnapInterfaceController.test.tsx @@ -1,5 +1,13 @@ +import { getPersistentState } from '@metamask/base-controller'; import type { SnapId } from '@metamask/snaps-sdk'; -import { form, image, input, panel, text } from '@metamask/snaps-sdk'; +import { + form, + image, + input, + panel, + text, + ContentType, +} from '@metamask/snaps-sdk'; import { Box, Field, @@ -29,6 +37,40 @@ jest.mock('@metamask/snaps-utils', () => ({ })); describe('SnapInterfaceController', () => { + describe('constructor', () => { + it('persists notification interfaces', () => { + const rootMessenger = getRootSnapInterfaceControllerMessenger(); + const controllerMessenger = + getRestrictedSnapInterfaceControllerMessenger(rootMessenger); + + const controller = new SnapInterfaceController({ + messenger: controllerMessenger, + state: { + interfaces: { + // @ts-expect-error missing properties + '1': { + contentType: ContentType.Notification, + }, + // @ts-expect-error missing properties + '2': { + contentType: ContentType.Dialog, + }, + }, + }, + }); + + expect( + getPersistentState(controller.state, controller.metadata), + ).toStrictEqual({ + interfaces: { + '1': { + contentType: ContentType.Notification, + }, + }, + }); + }); + }); + describe('createInterface', () => { it('can create a new interface', async () => { const rootMessenger = getRootSnapInterfaceControllerMessenger(); @@ -160,6 +202,41 @@ describe('SnapInterfaceController', () => { expect(context).toStrictEqual({ foo: 'bar' }); }); + it('supports providing an interface content type', async () => { + const rootMessenger = getRootSnapInterfaceControllerMessenger(); + const controllerMessenger = + getRestrictedSnapInterfaceControllerMessenger(rootMessenger); + + /* eslint-disable-next-line no-new */ + new SnapInterfaceController({ + messenger: controllerMessenger, + }); + + const element = ( + + + foo + + + ); + + const id = await rootMessenger.call( + 'SnapInterfaceController:createInterface', + MOCK_SNAP_ID, + element, + { foo: 'bar' }, + ContentType.Notification, + ); + + const { contentType } = rootMessenger.call( + 'SnapInterfaceController:getInterface', + MOCK_SNAP_ID, + id, + ); + + expect(contentType).toStrictEqual(ContentType.Notification); + }); + it('throws if interface context is too large', async () => { const rootMessenger = getRootSnapInterfaceControllerMessenger(); const controllerMessenger = @@ -1068,6 +1145,51 @@ describe('SnapInterfaceController', () => { }); }); + describe('updateInterfaceContentType', () => { + it("can update an interface's content type", async () => { + const rootMessenger = getRootSnapInterfaceControllerMessenger(); + const controllerMessenger = + getRestrictedSnapInterfaceControllerMessenger(rootMessenger); + + /* eslint-disable-next-line no-new */ + new SnapInterfaceController({ + messenger: controllerMessenger, + }); + + const content = form({ name: 'foo', children: [input({ name: 'bar' })] }); + + let contentType; + + const id = await rootMessenger.call( + 'SnapInterfaceController:createInterface', + MOCK_SNAP_ID, + content, + ); + + contentType = rootMessenger.call( + 'SnapInterfaceController:getInterface', + MOCK_SNAP_ID, + id, + ).contentType; + + expect(contentType).toBeNull(); + + rootMessenger.call( + 'SnapInterfaceController:updateInterfaceContentType', + id, + ContentType.Dialog, + ); + + contentType = rootMessenger.call( + 'SnapInterfaceController:getInterface', + MOCK_SNAP_ID, + id, + ).contentType; + + expect(contentType).toStrictEqual(ContentType.Dialog); + }); + }); + describe('resolveInterface', () => { it('resolves the interface with the given value', async () => { const rootMessenger = getRootSnapInterfaceControllerMessenger(); diff --git a/packages/snaps-controllers/src/interface/SnapInterfaceController.ts b/packages/snaps-controllers/src/interface/SnapInterfaceController.ts index cca147b9d8..807a8ea36d 100644 --- a/packages/snaps-controllers/src/interface/SnapInterfaceController.ts +++ b/packages/snaps-controllers/src/interface/SnapInterfaceController.ts @@ -18,6 +18,7 @@ import type { ComponentOrElement, InterfaceContext, } from '@metamask/snaps-sdk'; +import { ContentType } from '@metamask/snaps-sdk'; import type { JSXElement } from '@metamask/snaps-sdk/jsx'; import { getJsonSizeUnsafe, validateJsxLinks } from '@metamask/snaps-utils'; import type { Json } from '@metamask/utils'; @@ -61,6 +62,11 @@ export type UpdateInterfaceState = { handler: SnapInterfaceController['updateInterfaceState']; }; +export type UpdateInterfaceContentType = { + type: `${typeof controllerName}:updateInterfaceContentType`; + handler: SnapInterfaceController['updateInterfaceContentType']; +}; + export type ResolveInterface = { type: `${typeof controllerName}:resolveInterface`; handler: SnapInterfaceController['resolveInterface']; @@ -84,6 +90,7 @@ export type SnapInterfaceControllerActions = | UpdateInterface | DeleteInterface | UpdateInterfaceState + | UpdateInterfaceContentType | ResolveInterface | SnapInterfaceControllerGetStateAction; @@ -109,6 +116,7 @@ export type StoredInterface = { content: JSXElement; state: InterfaceState; context: InterfaceContext | null; + contentType: ContentType | null; }; export type SnapInterfaceControllerState = { @@ -132,7 +140,22 @@ export class SnapInterfaceController extends BaseController< super({ messenger, metadata: { - interfaces: { persist: false, anonymous: false }, + interfaces: { + persist: (interfaces: Record) => { + return Object.entries(interfaces).reduce< + Record + >((persistedInterfaces, [id, snapInterface]) => { + switch (snapInterface.contentType) { + case ContentType.Notification: + persistedInterfaces[id] = snapInterface; + return persistedInterfaces; + default: + return persistedInterfaces; + } + }, {}); + }, + anonymous: false, + }, }, name: controllerName, state: { interfaces: {}, ...state }, @@ -161,6 +184,11 @@ export class SnapInterfaceController extends BaseController< this.updateInterface.bind(this), ); + this.messagingSystem.registerActionHandler( + `${controllerName}:updateInterfaceContentType`, + this.updateInterfaceContentType.bind(this), + ); + this.messagingSystem.registerActionHandler( `${controllerName}:deleteInterface`, this.deleteInterface.bind(this), @@ -183,12 +211,14 @@ export class SnapInterfaceController extends BaseController< * @param snapId - The snap id that created the interface. * @param content - The interface content. * @param context - An optional interface context object. + * @param contentType - The type of content. * @returns The newly interface id. */ async createInterface( snapId: SnapId, content: ComponentOrElement, context?: InterfaceContext, + contentType?: ContentType, ) { const element = getJsxInterface(content); await this.#validateContent(element); @@ -205,6 +235,7 @@ export class SnapInterfaceController extends BaseController< content: castDraft(element), state: componentState, context: context ?? null, + contentType: contentType ?? null, }; }); @@ -255,6 +286,20 @@ export class SnapInterfaceController extends BaseController< }); } + /** + * Update the type of content in an interface. + * + * @param id - The interface id. + * @param contentType - The type of content. + */ + updateInterfaceContentType(id: string, contentType: ContentType) { + this.#validateContentType(contentType); + assert(this.state.interfaces[id], 'Interface does not exist.'); + this.update((draftState) => { + draftState.interfaces[id].contentType = contentType; + }); + } + /** * Delete an interface from state. * @@ -393,4 +438,17 @@ export class SnapInterfaceController extends BaseController< (id: string) => this.messagingSystem.call('SnapController:get', id), ); } + + /** + * Utility function to validate the type of interface content. + * Must be a value of the enum ContentType. + * Throws if the passed string is invalid. + * + * @param contentType - The content type. + */ + #validateContentType(contentType: string) { + if (!(contentType in ContentType)) { + throw new Error('Invalid content type.'); + } + } } diff --git a/packages/snaps-controllers/src/snaps/SnapController.ts b/packages/snaps-controllers/src/snaps/SnapController.ts index 532ddf41d1..d71259c0cf 100644 --- a/packages/snaps-controllers/src/snaps/SnapController.ts +++ b/packages/snaps-controllers/src/snaps/SnapController.ts @@ -47,7 +47,11 @@ import type { SnapId, ComponentOrElement, } from '@metamask/snaps-sdk'; -import { AuxiliaryFileEncoding, getErrorMessage } from '@metamask/snaps-sdk'; +import { + AuxiliaryFileEncoding, + getErrorMessage, + ContentType, +} from '@metamask/snaps-sdk'; import type { FetchedSnapFiles, InitialConnections, @@ -110,7 +114,7 @@ import type { Patch } from 'immer'; import { nanoid } from 'nanoid'; import { forceStrict, validateMachine } from '../fsm'; -import type { CreateInterface, GetInterface } from '../interface'; +import { type CreateInterface, type GetInterface } from '../interface'; import { log } from '../logging'; import type { ExecuteSnapAction, @@ -3311,16 +3315,20 @@ export class SnapController extends BaseController< * * @param snapId - The snap ID. * @param content - The initial interface content. + * @param contentType - The type of content. * @returns An identifier that can be used to identify the interface. */ async #createInterface( snapId: SnapId, content: ComponentOrElement, + contentType: ContentType, ): Promise { return this.messagingSystem.call( 'SnapInterfaceController:createInterface', snapId, content, + undefined, + contentType, ); } @@ -3358,10 +3366,20 @@ export class SnapController extends BaseController< // If a handler returns static content, we turn it into a dynamic UI if (castResult && hasProperty(castResult, 'content')) { const { content, ...rest } = castResult; + const getContentType = (handler: HandlerType) => { + if ( + handler === HandlerType.OnSignature || + handler === HandlerType.OnTransaction + ) { + return ContentType.Insight; + } + return ContentType.HomePage; + }; const id = await this.#createInterface( snapId, content as ComponentOrElement, + getContentType(handlerType), ); return { ...rest, id }; diff --git a/packages/snaps-controllers/src/test-utils/controller.ts b/packages/snaps-controllers/src/test-utils/controller.ts index bcb7e27708..809a4cc105 100644 --- a/packages/snaps-controllers/src/test-utils/controller.ts +++ b/packages/snaps-controllers/src/test-utils/controller.ts @@ -761,6 +761,7 @@ export const getRestrictedSnapInterfaceControllerMessenger = ( 'PhishingController:maybeUpdateState', 'ApprovalController:hasRequest', 'ApprovalController:acceptRequest', + 'SnapController:get', ], allowedEvents: [], }); diff --git a/packages/snaps-rpc-methods/src/restricted/dialog.test.tsx b/packages/snaps-rpc-methods/src/restricted/dialog.test.tsx index dd5d3c7fec..a5c72fcf77 100644 --- a/packages/snaps-rpc-methods/src/restricted/dialog.test.tsx +++ b/packages/snaps-rpc-methods/src/restricted/dialog.test.tsx @@ -1,6 +1,12 @@ import { PermissionType, SubjectType } from '@metamask/permission-controller'; import { rpcErrors } from '@metamask/rpc-errors'; -import { DialogType, heading, panel, text } from '@metamask/snaps-sdk'; +import { + ContentType, + DialogType, + heading, + panel, + text, +} from '@metamask/snaps-sdk'; import { Box, Text } from '@metamask/snaps-sdk/jsx'; import type { DialogMethodHooks } from './dialog'; @@ -30,6 +36,7 @@ describe('builder', () => { requestUserApproval: jest.fn(), createInterface: jest.fn(), getInterface: jest.fn(), + updateInterfaceContentType: jest.fn(), }, }), ).toStrictEqual({ @@ -50,6 +57,7 @@ describe('implementation', () => { getInterface: jest .fn() .mockReturnValue({ content: text('foo'), state: {}, snapId: 'foo' }), + updateInterfaceContentType: jest.fn(), } as DialogMethodHooks); it('accepts string dialog types', async () => { @@ -117,7 +125,11 @@ describe('implementation', () => { }, }); - expect(hooks.createInterface).toHaveBeenCalledWith('foo', content); + expect(hooks.createInterface).toHaveBeenCalledWith( + 'foo', + content, + ContentType.Dialog, + ); expect(hooks.requestUserApproval).toHaveBeenCalledTimes(1); expect(hooks.requestUserApproval).toHaveBeenCalledWith({ id: 'bar', @@ -137,6 +149,7 @@ describe('implementation', () => { getInterface: jest .fn() .mockReturnValue({ content: text('foo'), state: {}, snapId: 'foo' }), + updateInterfaceContentType: jest.fn(), }; const implementation = getDialogImplementation(hooks); @@ -177,7 +190,11 @@ describe('implementation', () => { }, }); - expect(hooks.createInterface).toHaveBeenCalledWith('foo', content); + expect(hooks.createInterface).toHaveBeenCalledWith( + 'foo', + content, + ContentType.Dialog, + ); expect(hooks.requestUserApproval).toHaveBeenCalledTimes(1); expect(hooks.requestUserApproval).toHaveBeenCalledWith({ id: undefined, @@ -209,7 +226,11 @@ describe('implementation', () => { }, }); - expect(hooks.createInterface).toHaveBeenCalledWith('foo', content); + expect(hooks.createInterface).toHaveBeenCalledWith( + 'foo', + content, + ContentType.Dialog, + ); expect(hooks.requestUserApproval).toHaveBeenCalledTimes(1); expect(hooks.requestUserApproval).toHaveBeenCalledWith({ id: undefined, @@ -229,6 +250,7 @@ describe('implementation', () => { getInterface: jest.fn().mockImplementation((_snapId, id) => { throw new Error(`Interface with id '${id}' not found.`); }), + updateInterfaceContentType: jest.fn(), }; const implementation = getDialogImplementation(hooks); diff --git a/packages/snaps-rpc-methods/src/restricted/dialog.ts b/packages/snaps-rpc-methods/src/restricted/dialog.ts index d4b5fb6f78..8b110edb5f 100644 --- a/packages/snaps-rpc-methods/src/restricted/dialog.ts +++ b/packages/snaps-rpc-methods/src/restricted/dialog.ts @@ -9,6 +9,7 @@ import { DialogType, enumValue, ComponentOrElementStruct, + ContentType, selectiveUnion, } from '@metamask/snaps-sdk'; import type { @@ -61,6 +62,7 @@ type RequestUserApproval = ( type CreateInterface = ( snapId: string, content: ComponentOrElement, + contentType?: ContentType, ) => Promise; type GetInterface = ( @@ -68,6 +70,11 @@ type GetInterface = ( id: string, ) => { content: ComponentOrElement; snapId: SnapId; state: InterfaceState }; +type UpdateInterfaceContentType = ( + id: string, + contentType: ContentType, +) => void; + export type DialogMethodHooks = { /** * @param opts - The `requestUserApproval` options. @@ -90,6 +97,11 @@ export type DialogMethodHooks = { * @param id - The interface ID. */ getInterface: GetInterface; + /** + * @param id - The interface ID. + * @param contentType - The type of the interface content. + */ + updateInterfaceContentType: UpdateInterfaceContentType; }; type DialogSpecificationBuilderOptions = { @@ -139,6 +151,7 @@ const methodHooks: MethodHooksObject = { requestUserApproval: true, createInterface: true, getInterface: true, + updateInterfaceContentType: true, }; export const dialogBuilder = Object.freeze({ @@ -249,6 +262,7 @@ export type DialogParameters = InferMatching< * This function should return a Promise that resolves with the appropriate value when the user has approved or rejected the request. * @param hooks.createInterface - A function that creates the interface in SnapInterfaceController. * @param hooks.getInterface - A function that gets an interface from SnapInterfaceController. + * @param hooks.updateInterfaceContentType - A function that updates an interface's content type. * @returns The method implementation which return value depends on the dialog * type, valid return types are: string, boolean, null. */ @@ -256,6 +270,7 @@ export function getDialogImplementation({ requestUserApproval, createInterface, getInterface, + updateInterfaceContentType, }: DialogMethodHooks) { return async function dialogImplementation( args: RestrictedMethodOptions, @@ -289,6 +304,7 @@ export function getDialogImplementation({ const id = await createInterface( origin, validatedParams.content as Component, + ContentType.Dialog, ); return requestUserApproval({ @@ -300,6 +316,9 @@ export function getDialogImplementation({ } validateInterface(origin, validatedParams.id, getInterface); + // we update the content type here since if we are receiving this interface + // as an id that means it was already created with a snap_createInterface call + updateInterfaceContentType(validatedParams.id, ContentType.Dialog); return requestUserApproval({ id: diff --git a/packages/snaps-sdk/src/types/interface.ts b/packages/snaps-sdk/src/types/interface.ts index 3e8290cb3c..a587a0283c 100644 --- a/packages/snaps-sdk/src/types/interface.ts +++ b/packages/snaps-sdk/src/types/interface.ts @@ -45,3 +45,10 @@ export const ComponentOrElementStruct = selectiveUnion((value) => { export const InterfaceContextStruct = record(string(), JsonStruct); export type InterfaceContext = Infer; + +export enum ContentType { + Insight = 'Insight', + Dialog = 'Dialog', + Notification = 'Notification', + HomePage = 'HomePage', +}