diff --git a/packages/examples/packages/manage-state/src/index.test.ts b/packages/examples/packages/manage-state/src/index.test.ts index f2a61df6f6..09182c23c7 100644 --- a/packages/examples/packages/manage-state/src/index.test.ts +++ b/packages/examples/packages/manage-state/src/index.test.ts @@ -20,6 +20,244 @@ describe('onRpcRequest', () => { }); }); + describe('setState', () => { + it('sets the state to the params', async () => { + const { request } = await installSnap(); + + expect( + await request({ + method: 'setState', + params: { + value: { + items: ['foo'], + }, + }, + }), + ).toRespondWith(null); + + expect( + await request({ + method: 'getState', + }), + ).toRespondWith({ + items: ['foo'], + }); + }); + + it('sets the state at a specific key', async () => { + const { request } = await installSnap(); + + expect( + await request({ + method: 'setState', + params: { + value: 'foo', + key: 'nested.key', + }, + }), + ).toRespondWith(null); + + expect( + await request({ + method: 'getState', + }), + ).toRespondWith({ + nested: { + key: 'foo', + }, + }); + }); + + it('sets the unencrypted state to the params', async () => { + const { request } = await installSnap(); + + expect( + await request({ + method: 'setState', + params: { + value: { + items: ['foo'], + }, + encrypted: false, + }, + }), + ).toRespondWith(null); + + expect( + await request({ + method: 'getState', + }), + ).toRespondWith(null); + + expect( + await request({ + method: 'getState', + params: { + encrypted: false, + }, + }), + ).toRespondWith({ + items: ['foo'], + }); + }); + + it('throws if the state is not an object and no key is specified', async () => { + const { request } = await installSnap(); + + const response = await request({ + method: 'setState', + params: { + value: 'foo', + }, + }); + + expect(response).toRespondWithError( + expect.objectContaining({ + code: -32602, + message: + 'Invalid params: Value must be an object if key is not provided.', + }), + ); + }); + }); + + describe('getState', () => { + it('returns `null` if no state has been set', async () => { + const { request } = await installSnap(); + + const response = await request({ + method: 'getState', + }); + + expect(response).toRespondWith(null); + }); + + it('returns the state', async () => { + const { request } = await installSnap(); + + await request({ + method: 'setState', + params: { + value: { + items: ['foo'], + }, + }, + }); + + const response = await request({ + method: 'getState', + }); + + expect(response).toRespondWith({ + items: ['foo'], + }); + }); + + it('returns the state at a specific key', async () => { + const { request } = await installSnap(); + + await request({ + method: 'setState', + params: { + value: { + nested: { + key: 'foo', + }, + }, + }, + }); + + const response = await request({ + method: 'getState', + params: { + key: 'nested.key', + }, + }); + + expect(response).toRespondWith('foo'); + }); + + it('returns the unencrypted state', async () => { + const { request } = await installSnap(); + + await request({ + method: 'setState', + params: { + value: { + items: ['foo'], + }, + encrypted: false, + }, + }); + + const response = await request({ + method: 'getState', + params: { + encrypted: false, + }, + }); + + expect(response).toRespondWith({ + items: ['foo'], + }); + }); + }); + + describe('clearState', () => { + it('clears the state', async () => { + const { request } = await installSnap(); + + await request({ + method: 'setState', + params: { + value: { + items: ['foo'], + }, + }, + }); + + await request({ + method: 'clearState', + }); + + const response = await request({ + method: 'getState', + }); + + expect(response).toRespondWith(null); + }); + + it('clears the unencrypted state', async () => { + const { request } = await installSnap(); + + await request({ + method: 'setState', + params: { + value: { + items: ['foo'], + }, + encrypted: false, + }, + }); + + await request({ + method: 'clearState', + params: { + encrypted: false, + }, + }); + + const response = await request({ + method: 'getState', + params: { + encrypted: false, + }, + }); + + expect(response).toRespondWith(null); + }); + }); + describe('legacy_setState', () => { it('sets the state to the params', async () => { const { request } = await installSnap(); diff --git a/packages/snaps-simulation/src/controllers.test.ts b/packages/snaps-simulation/src/controllers.test.ts index f95d661a01..d3f927a866 100644 --- a/packages/snaps-simulation/src/controllers.test.ts +++ b/packages/snaps-simulation/src/controllers.test.ts @@ -5,10 +5,10 @@ import { } from '@metamask/permission-controller'; import { getControllers } from './controllers'; -import type { MiddlewareHooks } from './simulation'; +import type { RestrictedMiddlewareHooks } from './simulation'; import { getMockOptions } from './test-utils'; -const MOCK_HOOKS: MiddlewareHooks = { +const MOCK_HOOKS: RestrictedMiddlewareHooks = { getIsLocked: jest.fn(), getMnemonic: jest.fn(), getSnapFile: jest.fn(), diff --git a/packages/snaps-simulation/src/controllers.ts b/packages/snaps-simulation/src/controllers.ts index b67989d539..583c798461 100644 --- a/packages/snaps-simulation/src/controllers.ts +++ b/packages/snaps-simulation/src/controllers.ts @@ -29,7 +29,7 @@ import { getSafeJson } from '@metamask/utils'; import { getPermissionSpecifications } from './methods'; import { UNRESTRICTED_METHODS } from './methods/constants'; import type { SimulationOptions } from './options'; -import type { MiddlewareHooks } from './simulation'; +import type { RestrictedMiddlewareHooks } from './simulation'; import type { RunSagaFunction } from './store'; export type RootControllerAllowedActions = @@ -49,7 +49,7 @@ export type RootControllerMessenger = ControllerMessenger< export type GetControllersOptions = { controllerMessenger: ControllerMessenger; - hooks: MiddlewareHooks; + hooks: RestrictedMiddlewareHooks; runSaga: RunSagaFunction; options: SimulationOptions; }; diff --git a/packages/snaps-simulation/src/methods/hooks/index.ts b/packages/snaps-simulation/src/methods/hooks/index.ts index cd5c5bcf95..4c73b7e698 100644 --- a/packages/snaps-simulation/src/methods/hooks/index.ts +++ b/packages/snaps-simulation/src/methods/hooks/index.ts @@ -1,5 +1,6 @@ export * from './get-preferences'; +export * from './interface'; export * from './notifications'; +export * from './permitted'; export * from './request-user-approval'; export * from './state'; -export * from './interface'; diff --git a/packages/snaps-simulation/src/methods/hooks/permitted/index.ts b/packages/snaps-simulation/src/methods/hooks/permitted/index.ts new file mode 100644 index 0000000000..da88543493 --- /dev/null +++ b/packages/snaps-simulation/src/methods/hooks/permitted/index.ts @@ -0,0 +1 @@ +export * from './state'; diff --git a/packages/snaps-simulation/src/methods/hooks/permitted/state.test.ts b/packages/snaps-simulation/src/methods/hooks/permitted/state.test.ts new file mode 100644 index 0000000000..c92fdf0a8a --- /dev/null +++ b/packages/snaps-simulation/src/methods/hooks/permitted/state.test.ts @@ -0,0 +1,119 @@ +import { createStore, getState, setState } from '../../../store'; +import { getMockOptions } from '../../../test-utils'; +import { + getPermittedClearSnapStateMethodImplementation, + getPermittedGetSnapStateMethodImplementation, + getPermittedUpdateSnapStateMethodImplementation, +} from './state'; + +describe('getPermittedGetSnapStateMethodImplementation', () => { + it('returns the implementation of the `getSnapState` hook', async () => { + const { store, runSaga } = createStore(getMockOptions()); + const fn = getPermittedGetSnapStateMethodImplementation(runSaga); + + expect(await fn(true)).toBeNull(); + + store.dispatch( + setState({ + state: JSON.stringify({ + foo: 'bar', + }), + encrypted: true, + }), + ); + + expect(await fn(true)).toStrictEqual({ + foo: 'bar', + }); + }); + + it('returns the implementation of the `getSnapState` hook for unencrypted state', async () => { + const { store, runSaga } = createStore(getMockOptions()); + const fn = getPermittedGetSnapStateMethodImplementation(runSaga); + + expect(await fn(false)).toBeNull(); + + store.dispatch( + setState({ + state: JSON.stringify({ + foo: 'bar', + }), + encrypted: false, + }), + ); + + expect(await fn(false)).toStrictEqual({ + foo: 'bar', + }); + }); +}); + +describe('getPermittedUpdateSnapStateMethodImplementation', () => { + it('returns the implementation of the `updateSnapState` hook', async () => { + const { store, runSaga } = createStore(getMockOptions()); + const fn = getPermittedUpdateSnapStateMethodImplementation(runSaga); + + expect(getState(true)(store.getState())).toBeNull(); + + await fn({ foo: 'bar' }, true); + + expect(getState(true)(store.getState())).toStrictEqual( + JSON.stringify({ + foo: 'bar', + }), + ); + }); + + it('returns the implementation of the `updateSnapState` hook for unencrypted state', async () => { + const { store, runSaga } = createStore(getMockOptions()); + const fn = getPermittedUpdateSnapStateMethodImplementation(runSaga); + + expect(getState(false)(store.getState())).toBeNull(); + + await fn({ foo: 'bar' }, false); + + expect(getState(false)(store.getState())).toStrictEqual( + JSON.stringify({ + foo: 'bar', + }), + ); + }); +}); + +describe('getPermittedClearSnapStateMethodImplementation', () => { + it('returns the implementation of the `clearSnapState` hook', async () => { + const { store, runSaga } = createStore(getMockOptions()); + const fn = getPermittedClearSnapStateMethodImplementation(runSaga); + + store.dispatch( + setState({ + state: JSON.stringify({ + foo: 'bar', + }), + encrypted: true, + }), + ); + + await fn(true); + + expect(getState(true)(store.getState())).toBeNull(); + }); + + it('returns the implementation of the `clearSnapState` hook for unencrypted state', async () => { + const { store, runSaga } = createStore(getMockOptions()); + const fn = getPermittedClearSnapStateMethodImplementation(runSaga); + + store.dispatch( + setState({ + state: JSON.stringify({ + foo: 'bar', + }), + encrypted: false, + }), + ); + + await fn(false); + + expect(getState(false)(store.getState())).toBeNull(); + }); +}); diff --git a/packages/snaps-simulation/src/methods/hooks/permitted/state.ts b/packages/snaps-simulation/src/methods/hooks/permitted/state.ts new file mode 100644 index 0000000000..2d387f971b --- /dev/null +++ b/packages/snaps-simulation/src/methods/hooks/permitted/state.ts @@ -0,0 +1,90 @@ +import { parseJson } from '@metamask/snaps-utils'; +import type { Json } from '@metamask/utils'; +import type { SagaIterator } from 'redux-saga'; +import { put, select } from 'redux-saga/effects'; + +import type { RunSagaFunction } from '../../../store'; +import { clearState, getState, setState } from '../../../store'; + +/** + * Get the Snap state from the store. + * + * @param encrypted - Whether to get the encrypted or unencrypted state. + * Defaults to encrypted state. + * @returns The state of the Snap. + * @yields Selects the state from the store. + */ +function* getSnapStateImplementation(encrypted: boolean): SagaIterator { + const state = yield select(getState(encrypted)); + // TODO: Use actual decryption implementation + return parseJson(state); +} + +/** + * Get the implementation of the `getSnapState` hook. + * + * @param runSaga - The function to run a saga outside the usual Redux flow. + * @returns The implementation of the `getSnapState` hook. + */ +export function getPermittedGetSnapStateMethodImplementation( + runSaga: RunSagaFunction, +) { + return async (...args: Parameters) => { + return await runSaga(getSnapStateImplementation, ...args).toPromise(); + }; +} + +/** + * Update the Snap state in the store. + * + * @param newState - The new state. + * @param encrypted - Whether to update the encrypted or unencrypted state. + * Defaults to encrypted state. + * @yields Puts the new state in the store. + */ +function* updateSnapStateImplementation( + newState: Record, + encrypted: boolean, +): SagaIterator { + // TODO: Use actual encryption implementation + yield put(setState({ state: JSON.stringify(newState), encrypted })); +} + +/** + * Get the implementation of the `updateSnapState` hook. + * + * @param runSaga - The function to run a saga outside the usual Redux flow. + * @returns The implementation of the `updateSnapState` hook. + */ +export function getPermittedUpdateSnapStateMethodImplementation( + runSaga: RunSagaFunction, +) { + return async (...args: Parameters) => { + return await runSaga(updateSnapStateImplementation, ...args).toPromise(); + }; +} + +/** + * Clear the Snap state in the store. + * + * @param encrypted - Whether to clear the encrypted or unencrypted state. + * Defaults to encrypted state. + * @yields Puts the new state in the store. + */ +function* clearSnapStateImplementation(encrypted: boolean): SagaIterator { + yield put(clearState({ encrypted })); +} + +/** + * Get the implementation of the `clearSnapState` hook. + * + * @param runSaga - The function to run a saga outside the usual Redux flow. + * @returns The implementation of the `clearSnapState` hook. + */ +export function getPermittedClearSnapStateMethodImplementation( + runSaga: RunSagaFunction, +) { + return async (...args: Parameters) => { + runSaga(clearSnapStateImplementation, ...args).result(); + }; +} diff --git a/packages/snaps-simulation/src/methods/specifications.test.ts b/packages/snaps-simulation/src/methods/specifications.test.ts index 2c556403e4..9b804b4586 100644 --- a/packages/snaps-simulation/src/methods/specifications.test.ts +++ b/packages/snaps-simulation/src/methods/specifications.test.ts @@ -5,7 +5,7 @@ import { } from '@metamask/snaps-utils/test-utils'; import { getControllers, registerSnap } from '../controllers'; -import type { MiddlewareHooks } from '../simulation'; +import type { RestrictedMiddlewareHooks } from '../simulation'; import { getMockOptions } from '../test-utils/options'; import { asyncResolve, @@ -14,7 +14,7 @@ import { resolve, } from './specifications'; -const MOCK_HOOKS: MiddlewareHooks = { +const MOCK_HOOKS: RestrictedMiddlewareHooks = { getMnemonic: jest.fn(), getSnapFile: jest.fn(), createInterface: jest.fn(), diff --git a/packages/snaps-simulation/src/methods/specifications.ts b/packages/snaps-simulation/src/methods/specifications.ts index ed255dea93..246a04e28c 100644 --- a/packages/snaps-simulation/src/methods/specifications.ts +++ b/packages/snaps-simulation/src/methods/specifications.ts @@ -60,7 +60,7 @@ export function resolve(result: unknown) { * resolve with `undefined`. * @returns The function implementation. */ -export function asyncResolve(result?: unknown) { +export function asyncResolve(result?: Type) { return async () => result; } diff --git a/packages/snaps-simulation/src/middleware/engine.test.ts b/packages/snaps-simulation/src/middleware/engine.test.ts index c11ade60c9..b506475125 100644 --- a/packages/snaps-simulation/src/middleware/engine.test.ts +++ b/packages/snaps-simulation/src/middleware/engine.test.ts @@ -7,11 +7,18 @@ describe('createJsonRpcEngine', () => { const { store } = createStore(getMockOptions()); const engine = createJsonRpcEngine({ store, - hooks: { + restrictedHooks: { getMnemonic: jest.fn(), - getSnapFile: jest.fn().mockResolvedValue('foo'), getIsLocked: jest.fn(), + getClientCryptography: jest.fn(), + }, + permittedHooks: { + getSnapFile: jest.fn().mockResolvedValue('foo'), + getSnapState: jest.fn(), + updateSnapState: jest.fn(), + clearSnapState: jest.fn(), getInterfaceState: jest.fn(), + getInterfaceContext: jest.fn(), createInterface: jest.fn(), updateInterface: jest.fn(), resolveInterface: jest.fn(), diff --git a/packages/snaps-simulation/src/middleware/engine.ts b/packages/snaps-simulation/src/middleware/engine.ts index 026a8c7590..20a39c3480 100644 --- a/packages/snaps-simulation/src/middleware/engine.ts +++ b/packages/snaps-simulation/src/middleware/engine.ts @@ -6,14 +6,18 @@ import { createSnapsMethodMiddleware } from '@metamask/snaps-rpc-methods'; import type { Json } from '@metamask/utils'; import { DEFAULT_JSON_RPC_ENDPOINT } from '../constants'; -import type { MiddlewareHooks } from '../simulation'; +import type { + PermittedMiddlewareHooks, + RestrictedMiddlewareHooks, +} from '../simulation'; import type { Store } from '../store'; import { createInternalMethodsMiddleware } from './internal-methods'; import { createMockMiddleware } from './mock'; export type CreateJsonRpcEngineOptions = { store: Store; - hooks: MiddlewareHooks; + restrictedHooks: RestrictedMiddlewareHooks; + permittedHooks: PermittedMiddlewareHooks; permissionMiddleware: JsonRpcMiddleware; endpoint?: string; }; @@ -26,21 +30,27 @@ export type CreateJsonRpcEngineOptions = { * * @param options - The options to use when creating the engine. * @param options.store - The Redux store to use. - * @param options.hooks - Any hooks used by the middleware handlers. + * @param options.restrictedHooks - Any hooks used by the middleware handlers. + * @param options.permittedHooks - Any hooks used by the middleware handlers. * @param options.permissionMiddleware - The permission middleware to use. * @param options.endpoint - The JSON-RPC endpoint to use for Ethereum requests. * @returns A JSON-RPC engine. */ export function createJsonRpcEngine({ store, - hooks, + restrictedHooks, + permittedHooks, permissionMiddleware, endpoint = DEFAULT_JSON_RPC_ENDPOINT, }: CreateJsonRpcEngineOptions) { const engine = new JsonRpcEngine(); engine.push(createMockMiddleware(store)); - engine.push(createInternalMethodsMiddleware(hooks)); - engine.push(createSnapsMethodMiddleware(true, hooks)); + + // The hooks here do not match the hooks used by the clients, so this + // middleware should not be used outside of the simulation environment. + engine.push(createInternalMethodsMiddleware(restrictedHooks)); + engine.push(createSnapsMethodMiddleware(true, permittedHooks)); + engine.push(permissionMiddleware); engine.push( createFetchMiddleware({ diff --git a/packages/snaps-simulation/src/simulation.test.ts b/packages/snaps-simulation/src/simulation.test.ts index fc5adf79d0..f6d0f186d6 100644 --- a/packages/snaps-simulation/src/simulation.test.ts +++ b/packages/snaps-simulation/src/simulation.test.ts @@ -12,8 +12,13 @@ import { VirtualFile } from '@metamask/snaps-utils'; import { getSnapManifest } from '@metamask/snaps-utils/test-utils'; import { DEFAULT_SRP } from './constants'; -import { getHooks, installSnap, registerActions } from './simulation'; -import { createStore, setInterface } from './store'; +import { + getPermittedHooks, + getRestrictedHooks, + installSnap, + registerActions, +} from './simulation'; +import { createStore, setInterface, setState } from './store'; import { getMockOptions, getMockServer, @@ -204,27 +209,96 @@ describe('installSnap', () => { }); }); -describe('getHooks', () => { +describe('getRestrictedHooks', () => { + it('returns the `getMnemonic` hook', async () => { + const { getMnemonic } = getRestrictedHooks(getMockOptions()); + expect(await getMnemonic()).toStrictEqual( + mnemonicPhraseToBytes(DEFAULT_SRP), + ); + }); + + it('returns the `getIsLocked` hook', async () => { + const { getIsLocked } = getRestrictedHooks(getMockOptions()); + expect(getIsLocked()).toBe(false); + }); + + it('returns the `getClientCryptography` hook', async () => { + const { getClientCryptography } = getRestrictedHooks(getMockOptions()); + + expect(getClientCryptography()).toStrictEqual({}); + }); +}); + +describe('getPermittedHooks', () => { + const { runSaga, store } = createStore(getMockOptions()); const controllerMessenger = getRootControllerMessenger(); - it('returns the `getMnemonic` hook', async () => { - const { snapId, close } = await getMockServer(); + it('returns the `hasPermission` hook', async () => { + const { snapId, close } = await getMockServer({ + manifest: getSnapManifest(), + }); const location = detectSnapLocation(snapId, { allowLocal: true, }); + const snapFiles = await fetchSnap(snapId, location); - const { getMnemonic } = getHooks( - getMockOptions(), + const { hasPermission } = getPermittedHooks( + snapId, snapFiles, + controllerMessenger, + runSaga, + ); + + expect(hasPermission('snap_manageState')).toBe(true); + + await close(); + }); + + it('returns the `getUnlockPromise` hook', async () => { + const { snapId, close } = await getMockServer({ + manifest: getSnapManifest(), + }); + + const location = detectSnapLocation(snapId, { + allowLocal: true, + }); + + const snapFiles = await fetchSnap(snapId, location); + + const { getUnlockPromise } = getPermittedHooks( snapId, + snapFiles, controllerMessenger, + runSaga, ); - expect(await getMnemonic()).toStrictEqual( - mnemonicPhraseToBytes(DEFAULT_SRP), + + expect(await getUnlockPromise(true)).toBeUndefined(); + + await close(); + }); + + it('returns the `getIsLocked` hook', async () => { + const { snapId, close } = await getMockServer({ + manifest: getSnapManifest(), + }); + + const location = detectSnapLocation(snapId, { + allowLocal: true, + }); + + const snapFiles = await fetchSnap(snapId, location); + + const { getIsLocked } = getPermittedHooks( + snapId, + snapFiles, + controllerMessenger, + runSaga, ); + expect(getIsLocked()).toBe(false); + await close(); }); @@ -245,20 +319,108 @@ describe('getHooks', () => { const location = detectSnapLocation(snapId, { allowLocal: true, }); + const snapFiles = await fetchSnap(snapId, location); - const { getSnapFile } = getHooks( - getMockOptions(), - snapFiles, + const { getSnapFile } = getPermittedHooks( snapId, + snapFiles, controllerMessenger, + runSaga, ); + const file = await getSnapFile('foo.json', AuxiliaryFileEncoding.Utf8); expect(file).toStrictEqual(value); await close(); }); + it('returns the `getSnapState` hook', async () => { + const { snapId, close } = await getMockServer({ + manifest: getSnapManifest(), + }); + + const location = detectSnapLocation(snapId, { + allowLocal: true, + }); + + const snapFiles = await fetchSnap(snapId, location); + + const { getSnapState } = getPermittedHooks( + snapId, + snapFiles, + controllerMessenger, + runSaga, + ); + + store.dispatch( + setState({ state: JSON.stringify({ foo: 'bar' }), encrypted: true }), + ); + + expect(await getSnapState(true)).toStrictEqual({ foo: 'bar' }); + + await close(); + }); + + it('returns the `updateSnapState` hook', async () => { + const { snapId, close } = await getMockServer({ + manifest: getSnapManifest(), + }); + + const location = detectSnapLocation(snapId, { + allowLocal: true, + }); + + const snapFiles = await fetchSnap(snapId, location); + + const { updateSnapState } = getPermittedHooks( + snapId, + snapFiles, + controllerMessenger, + runSaga, + ); + + store.dispatch( + setState({ state: JSON.stringify({ foo: 'bar' }), encrypted: true }), + ); + + await updateSnapState({ bar: 'baz' }, true); + + expect(store.getState().state.encrypted).toStrictEqual( + JSON.stringify({ bar: 'baz' }), + ); + + await close(); + }); + + it('returns the `clearSnapState` hook', async () => { + const { snapId, close } = await getMockServer({ + manifest: getSnapManifest(), + }); + + const location = detectSnapLocation(snapId, { + allowLocal: true, + }); + + const snapFiles = await fetchSnap(snapId, location); + + const { clearSnapState } = getPermittedHooks( + snapId, + snapFiles, + controllerMessenger, + runSaga, + ); + + store.dispatch( + setState({ state: JSON.stringify({ foo: 'bar' }), encrypted: true }), + ); + + await clearSnapState(true); + + expect(store.getState().state.encrypted).toBeNull(); + + await close(); + }); it('returns the `createInterface` hook', async () => { // eslint-disable-next-line no-new new SnapInterfaceController({ @@ -276,14 +438,16 @@ describe('getHooks', () => { const location = detectSnapLocation(snapId, { allowLocal: true, }); + const snapFiles = await fetchSnap(snapId, location); - const { createInterface } = getHooks( - getMockOptions(), - snapFiles, + const { createInterface } = getPermittedHooks( snapId, + snapFiles, controllerMessenger, + runSaga, ); + await createInterface(content); expect(controllerMessenger.call).toHaveBeenCalledWith( @@ -312,13 +476,14 @@ describe('getHooks', () => { const location = detectSnapLocation(snapId, { allowLocal: true, }); + const snapFiles = await fetchSnap(snapId, location); - const { createInterface, updateInterface } = getHooks( - getMockOptions(), - snapFiles, + const { createInterface, updateInterface } = getPermittedHooks( snapId, + snapFiles, controllerMessenger, + runSaga, ); const id = await createInterface(text('foo')); @@ -352,13 +517,14 @@ describe('getHooks', () => { const location = detectSnapLocation(snapId, { allowLocal: true, }); + const snapFiles = await fetchSnap(snapId, location); - const { createInterface, getInterfaceState } = getHooks( - getMockOptions(), - snapFiles, + const { createInterface, getInterfaceState } = getPermittedHooks( snapId, + snapFiles, controllerMessenger, + runSaga, ); const id = await createInterface(text('foo')); @@ -392,13 +558,14 @@ describe('getHooks', () => { const location = detectSnapLocation(snapId, { allowLocal: true, }); + const snapFiles = await fetchSnap(snapId, location); - const { createInterface, getInterfaceContext } = getHooks( - getMockOptions(), - snapFiles, + const { createInterface, getInterfaceContext } = getPermittedHooks( snapId, + snapFiles, controllerMessenger, + runSaga, ); const id = await createInterface(text('foo'), { bar: 'baz' }); @@ -428,30 +595,23 @@ describe('getHooks', () => { const { snapId, close } = await getMockServer({ manifest: getSnapManifest(), }); - const id = await snapInterfaceController.createInterface( - snapId, - text('foo'), - ); const location = detectSnapLocation(snapId, { allowLocal: true, }); + const snapFiles = await fetchSnap(snapId, location); - const { resolveInterface } = getHooks( - getMockOptions({ - state: { - ui: { - current: { - id, - type: DIALOG_APPROVAL_TYPES.default, - }, - }, - }, - }), - snapFiles, + const id = await snapInterfaceController.createInterface( snapId, + text('foo'), + ); + + const { resolveInterface } = getPermittedHooks( + snapId, + snapFiles, controllerMessenger, + runSaga, ); await resolveInterface(id, 'foobar'); @@ -466,45 +626,6 @@ describe('getHooks', () => { await close(); }); - - it('returns the `getIsLocked` hook', async () => { - const { snapId, close } = await getMockServer(); - - const location = detectSnapLocation(snapId, { - allowLocal: true, - }); - const snapFiles = await fetchSnap(snapId, location); - - const { getIsLocked } = getHooks( - getMockOptions(), - snapFiles, - snapId, - controllerMessenger, - ); - expect(getIsLocked()).toBe(false); - - await close(); - }); - - it('returns the `getClientCryptography` hook', async () => { - const { snapId, close } = await getMockServer(); - - const location = detectSnapLocation(snapId, { - allowLocal: true, - }); - const snapFiles = await fetchSnap(snapId, location); - - const { getClientCryptography } = getHooks( - getMockOptions(), - snapFiles, - snapId, - controllerMessenger, - ); - - expect(getClientCryptography()).toStrictEqual({}); - - await close(); - }); }); describe('registerActions', () => { diff --git a/packages/snaps-simulation/src/simulation.ts b/packages/snaps-simulation/src/simulation.ts index a7faa679c0..b9305058d2 100644 --- a/packages/snaps-simulation/src/simulation.ts +++ b/packages/snaps-simulation/src/simulation.ts @@ -38,7 +38,12 @@ import { getSnapFile } from './files'; import type { SnapHelpers } from './helpers'; import { getHelpers } from './helpers'; import { resolveWithSaga } from './interface'; -import { getEndowments } from './methods'; +import { asyncResolve, getEndowments } from './methods'; +import { + getPermittedClearSnapStateMethodImplementation, + getPermittedGetSnapStateMethodImplementation, + getPermittedUpdateSnapStateMethodImplementation, +} from './methods/hooks'; import { createJsonRpcEngine } from './middleware'; import type { SimulationOptions, SimulationUserOptions } from './options'; import { getOptions } from './options'; @@ -93,7 +98,7 @@ export type InstalledSnap = { runSaga: RunSagaFunction; }; -export type MiddlewareHooks = { +export type RestrictedMiddlewareHooks = { /** * A hook that returns the user's secret recovery phrase. * @@ -103,7 +108,48 @@ export type MiddlewareHooks = { getMnemonic: (keyringId?: string) => Promise; /** - * A hook that returns the Snap's auxiliary file for the given path. + * A hook that returns whether the client is locked or not. + * + * @returns A boolean flag signaling whether the client is locked. + */ + getIsLocked: () => boolean; + + /** + * Get the cryptographic functions to use for the client. This may return an + * empty object to fall back to the default cryptographic functions. + * + * @returns The cryptographic functions to use for the client. + */ + getClientCryptography: () => CryptographicFunctions; +}; + +export type PermittedMiddlewareHooks = { + /** + * A hook that gets whether the requesting origin has a given permission. + * + * @param permissionName - The name of the permission to check. + * @returns Whether the origin has the permission. + */ + hasPermission: (permissionName: string) => boolean; + + /** + * A hook that returns a promise that resolves once the extension is unlocked. + * + * @param shouldShowUnlockRequest - Whether to show the unlock request. + * @returns A promise that resolves once the extension is unlocked. + */ + getUnlockPromise: (shouldShowUnlockRequest: boolean) => Promise; + + /** + * A hook that returns whether the client is locked or not. + * + * @returns A boolean flag signaling whether the client is locked. + */ + getIsLocked: () => boolean; + + /** + * A hook that returns the Snap's auxiliary file for the given path. This hook + * is bound to the Snap ID. * * @param path - The path of the auxiliary file to get. * @param encoding - The encoding to use when returning the file. @@ -115,27 +161,80 @@ export type MiddlewareHooks = { ) => Promise; /** - * A hook that returns whether the client is locked or not. + * A hook that gets the state of the Snap. This hook is bound to the Snap ID. * - * @returns A boolean flag signaling whether the client is locked. + * @param encrypted - Whether to get the encrypted or unencrypted state. + * @returns The current state of the Snap. */ - getIsLocked: () => boolean; + getSnapState: (encrypted: boolean) => Promise>; /** - * Get the cryptographic functions to use for the client. This may return an - * empty object to fall back to the default cryptographic functions. + * A hook that updates the state of the Snap. This hook is bound to the Snap + * ID. * - * @returns The cryptographic functions to use for the client. + * @param newState - The new state. + * @param encrypted - Whether to update the encrypted or unencrypted state. */ - getClientCryptography: () => CryptographicFunctions; + updateSnapState: ( + newState: Record, + encrypted: boolean, + ) => Promise; + /** + * A hook that clears the state of the Snap. This hook is bound to the Snap + * ID. + * + * @param encrypted - Whether to clear the encrypted or unencrypted state. + */ + clearSnapState: (encrypted: boolean) => Promise; + + /** + * A hook that creates an interface for the Snap. This hook is bound to the + * Snap ID. + * + * @param content - The content of the interface. + * @param context - The context of the interface. + * @returns The ID of the created interface. + */ createInterface: ( content: Component, context?: InterfaceContext, ) => Promise; + + /** + * A hook that updates an interface for the Snap. This hook is bound to the + * Snap ID. + * + * @param id - The ID of the interface to update. + * @param content - The content of the interface. + */ updateInterface: (id: string, content: Component) => Promise; + + /** + * A hook that gets the state of an interface for the Snap. This hook is bound + * to the Snap ID. + * + * @param id - The ID of the interface to get. + * @returns The state of the interface. + */ getInterfaceState: (id: string) => InterfaceState; + + /** + * A hook that gets the context of an interface for the Snap. This hook is + * bound to the Snap ID. + * + * @param id - The ID of the interface to get. + * @returns The context of the interface. + */ getInterfaceContext: (id: string) => InterfaceContext | null; + + /** + * A hook that resolves an interface for the Snap. This hook is bound to the + * Snap ID. + * + * @param id - The ID of the interface to resolve. + * @param value - The value to resolve the interface with. + */ resolveInterface: (id: string, value: Json) => Promise; }; @@ -183,18 +282,25 @@ export async function installSnap< registerActions(controllerMessenger, runSaga); // Set up controllers and JSON-RPC stack. - const hooks = getHooks(options, snapFiles, snapId, controllerMessenger); + const restrictedHooks = getRestrictedHooks(options); + const permittedHooks = getPermittedHooks( + snapId, + snapFiles, + controllerMessenger, + runSaga, + ); const { subjectMetadataController, permissionController } = getControllers({ controllerMessenger, - hooks, + hooks: restrictedHooks, runSaga, options, }); const engine = createJsonRpcEngine({ store, - hooks, + restrictedHooks, + permittedHooks, permissionMiddleware: permissionController.createPermissionMiddleware({ origin: snapId, }), @@ -261,24 +367,42 @@ export async function installSnap< * Get the hooks for the simulation. * * @param options - The simulation options. - * @param snapFiles - The Snap files. - * @param snapId - The Snap ID. - * @param controllerMessenger - The controller messenger. * @returns The hooks for the simulation. */ -export function getHooks( +export function getRestrictedHooks( options: SimulationOptions, - snapFiles: FetchedSnapFiles, - snapId: SnapId, - controllerMessenger: RootControllerMessenger, -): MiddlewareHooks { +): RestrictedMiddlewareHooks { return { getMnemonic: async () => Promise.resolve(mnemonicPhraseToBytes(options.secretRecoveryPhrase)), - getSnapFile: async (path: string, encoding: AuxiliaryFileEncoding) => - await getSnapFile(snapFiles.auxiliaryFiles, path, encoding), getIsLocked: () => false, getClientCryptography: () => ({}), + }; +} + +/** + * Get the permitted hooks for the simulation. + * + * @param snapId - The ID of the Snap. + * @param snapFiles - The fetched Snap files. + * @param controllerMessenger - The controller messenger. + * @param runSaga - The run saga function. + * @returns The permitted hooks for the simulation. + */ +export function getPermittedHooks( + snapId: SnapId, + snapFiles: FetchedSnapFiles, + controllerMessenger: RootControllerMessenger, + runSaga: RunSagaFunction, +): PermittedMiddlewareHooks { + return { + hasPermission: () => true, + getUnlockPromise: asyncResolve(), + getIsLocked: () => false, + + getSnapFile: async (path: string, encoding: AuxiliaryFileEncoding) => + await getSnapFile(snapFiles.auxiliaryFiles, path, encoding), + createInterface: async (...args) => controllerMessenger.call( 'SnapInterfaceController:createInterface', @@ -309,6 +433,10 @@ export function getHooks( snapId, ...args, ), + + getSnapState: getPermittedGetSnapStateMethodImplementation(runSaga), + updateSnapState: getPermittedUpdateSnapStateMethodImplementation(runSaga), + clearSnapState: getPermittedClearSnapStateMethodImplementation(runSaga), }; }