diff --git a/packages/examples/packages/browserify-plugin/snap.manifest.json b/packages/examples/packages/browserify-plugin/snap.manifest.json index ee74bc2427..551a11c430 100644 --- a/packages/examples/packages/browserify-plugin/snap.manifest.json +++ b/packages/examples/packages/browserify-plugin/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snaps.git" }, "source": { - "shasum": "REliam7FRJGwKCI4NaC2G3mBSD5iYR7DCEhrXLcBDqA=", + "shasum": "82KbG3cf0wtxooJpWzHeM1g4FhO8O7zSYCAAGNPshfM=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/examples/packages/browserify/snap.manifest.json b/packages/examples/packages/browserify/snap.manifest.json index 6f6b0f910f..f12d23acb3 100644 --- a/packages/examples/packages/browserify/snap.manifest.json +++ b/packages/examples/packages/browserify/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snaps.git" }, "source": { - "shasum": "NItYOhAaWlS9q2r59/zlpoyVUyxojfsc5xMh65mLIwQ=", + "shasum": "5LsB950haZGnl0q5K7M4XgSh5J2e0p5O1Ptl/e6kpSQ=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/examples/packages/manage-state/package.json b/packages/examples/packages/manage-state/package.json index bca541d171..343524d21d 100644 --- a/packages/examples/packages/manage-state/package.json +++ b/packages/examples/packages/manage-state/package.json @@ -43,7 +43,8 @@ "test:watch": "jest --watch" }, "dependencies": { - "@metamask/snaps-sdk": "workspace:^" + "@metamask/snaps-sdk": "workspace:^", + "@metamask/utils": "^10.0.0" }, "devDependencies": { "@jest/globals": "^29.5.0", diff --git a/packages/examples/packages/manage-state/snap.manifest.json b/packages/examples/packages/manage-state/snap.manifest.json index bd60d0cc21..5baed7f48e 100644 --- a/packages/examples/packages/manage-state/snap.manifest.json +++ b/packages/examples/packages/manage-state/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snaps.git" }, "source": { - "shasum": "3jbTBm2Gtm5+qWdWgNR2sgwEGwWmKsGK7QPeXN9yOpE=", + "shasum": "fIXije73reQctVWFkOL9kdLWns7uDs7UWbPPL1J0f2o=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/examples/packages/manage-state/src/index.test.ts b/packages/examples/packages/manage-state/src/index.test.ts index 2f86d99f65..f2a61df6f6 100644 --- a/packages/examples/packages/manage-state/src/index.test.ts +++ b/packages/examples/packages/manage-state/src/index.test.ts @@ -20,13 +20,13 @@ describe('onRpcRequest', () => { }); }); - describe('setState', () => { + describe('legacy_setState', () => { it('sets the state to the params', async () => { const { request } = await installSnap(); expect( await request({ - method: 'setState', + method: 'legacy_setState', params: { items: ['foo'], }, @@ -35,7 +35,7 @@ describe('onRpcRequest', () => { expect( await request({ - method: 'getState', + method: 'legacy_getState', }), ).toRespondWith({ items: ['foo'], @@ -47,7 +47,7 @@ describe('onRpcRequest', () => { expect( await request({ - method: 'setState', + method: 'legacy_setState', params: { items: ['foo'], encrypted: false, @@ -57,7 +57,7 @@ describe('onRpcRequest', () => { expect( await request({ - method: 'getState', + method: 'legacy_getState', }), ).toRespondWith({ items: [], @@ -65,7 +65,7 @@ describe('onRpcRequest', () => { expect( await request({ - method: 'getState', + method: 'legacy_getState', params: { encrypted: false, }, @@ -76,12 +76,12 @@ describe('onRpcRequest', () => { }); }); - describe('getState', () => { + describe('legacy_getState', () => { it('returns the state if no state has been set', async () => { const { request } = await installSnap(); const response = await request({ - method: 'getState', + method: 'legacy_getState', }); expect(response).toRespondWith({ @@ -93,14 +93,14 @@ describe('onRpcRequest', () => { const { request } = await installSnap(); await request({ - method: 'setState', + method: 'legacy_setState', params: { items: ['foo'], }, }); const response = await request({ - method: 'getState', + method: 'legacy_getState', }); expect(response).toRespondWith({ @@ -118,7 +118,7 @@ describe('onRpcRequest', () => { }); const response = await request({ - method: 'getState', + method: 'legacy_getState', params: { encrypted: false, }, @@ -130,12 +130,12 @@ describe('onRpcRequest', () => { }); }); - describe('clearState', () => { + describe('legacy_clearState', () => { it('clears the state', async () => { const { request } = await installSnap(); await request({ - method: 'setState', + method: 'legacy_setState', params: { items: ['foo'], }, @@ -143,13 +143,13 @@ describe('onRpcRequest', () => { expect( await request({ - method: 'clearState', + method: 'legacy_clearState', }), ).toRespondWith(true); expect( await request({ - method: 'getState', + method: 'legacy_getState', }), ).toRespondWith({ items: [], @@ -160,7 +160,7 @@ describe('onRpcRequest', () => { const { request } = await installSnap(); await request({ - method: 'setState', + method: 'legacy_setState', params: { items: ['foo'], encrypted: false, @@ -169,7 +169,7 @@ describe('onRpcRequest', () => { expect( await request({ - method: 'clearState', + method: 'legacy_clearState', params: { encrypted: false, }, @@ -178,7 +178,7 @@ describe('onRpcRequest', () => { expect( await request({ - method: 'getState', + method: 'legacy_getState', params: { encrypted: false, }, diff --git a/packages/examples/packages/manage-state/src/index.ts b/packages/examples/packages/manage-state/src/index.ts index 32c5aa32ef..6b3fec410c 100644 --- a/packages/examples/packages/manage-state/src/index.ts +++ b/packages/examples/packages/manage-state/src/index.ts @@ -3,7 +3,12 @@ import { type OnRpcRequestHandler, } from '@metamask/snaps-sdk'; -import type { BaseParams, SetStateParams } from './types'; +import type { + BaseParams, + LegacyParams, + LegacySetStateParams, + SetStateParams, +} from './types'; import { clearState, getState, setState } from './utils'; /** @@ -34,21 +39,55 @@ export const onRpcRequest: OnRpcRequestHandler = async ({ request }) => { switch (request.method) { case 'setState': { const params = request.params as SetStateParams; + return await snap.request({ + method: 'snap_setState', + params: { + key: params?.key, + value: params?.value, + encrypted: params?.encrypted, + }, + }); + } + + case 'getState': { + const params = request.params as BaseParams; + return await snap.request({ + method: 'snap_getState', + params: { + key: params?.key, + encrypted: params?.encrypted, + }, + }); + } + case 'clearState': { + const params = request.params as BaseParams; + return await snap.request({ + method: 'snap_clearState', + params: { + encrypted: params?.encrypted, + }, + }); + } + + case 'legacy_setState': { + const params = request.params as LegacySetStateParams; if (params.items) { await setState({ items: params.items }, params.encrypted); } + return true; } - case 'getState': { - const params = request.params as BaseParams; + case 'legacy_getState': { + const params = request.params as LegacyParams; return await getState(params?.encrypted); } - case 'clearState': { - const params = request.params as BaseParams; + case 'legacy_clearState': { + const params = request.params as LegacyParams; await clearState(params?.encrypted); + return true; } diff --git a/packages/examples/packages/manage-state/src/types.ts b/packages/examples/packages/manage-state/src/types.ts index 4f696071b7..897d2a48a2 100644 --- a/packages/examples/packages/manage-state/src/types.ts +++ b/packages/examples/packages/manage-state/src/types.ts @@ -1,10 +1,19 @@ +import type { Json } from '@metamask/utils'; + import type { State } from './utils'; -export type BaseParams = { encrypted?: boolean }; +export type LegacyParams = { encrypted?: boolean }; + +export type BaseParams = { key?: string; encrypted?: boolean }; /** * The parameters for the `setState` JSON-RPC method. - * - * The current state will be merged with the new state. */ -export type SetStateParams = BaseParams & Partial; +export type SetStateParams = BaseParams & { + value: Json; +}; + +/** + * The parameters for the `legacy_setState` JSON-RPC method. + */ +export type LegacySetStateParams = LegacyParams & Partial; diff --git a/packages/snaps-rpc-methods/jest.config.js b/packages/snaps-rpc-methods/jest.config.js index f3813d88c0..3a4658da9f 100644 --- a/packages/snaps-rpc-methods/jest.config.js +++ b/packages/snaps-rpc-methods/jest.config.js @@ -10,10 +10,10 @@ module.exports = deepmerge(baseConfig, { ], coverageThreshold: { global: { - branches: 92.94, - functions: 97.29, - lines: 97.88, - statements: 97.41, + branches: 93.33, + functions: 97.46, + lines: 98.03, + statements: 97.61, }, }, }); diff --git a/packages/snaps-rpc-methods/src/permitted/clearState.test.ts b/packages/snaps-rpc-methods/src/permitted/clearState.test.ts new file mode 100644 index 0000000000..d3ca4987e2 --- /dev/null +++ b/packages/snaps-rpc-methods/src/permitted/clearState.test.ts @@ -0,0 +1,197 @@ +import { JsonRpcEngine } from '@metamask/json-rpc-engine'; +import { errorCodes } from '@metamask/rpc-errors'; +import type { ClearStateResult } from '@metamask/snaps-sdk'; +import type { JsonRpcRequest, PendingJsonRpcResponse } from '@metamask/utils'; + +import type { ClearStateParameters } from './clearState'; +import { clearStateHandler } from './clearState'; + +describe('snap_clearState', () => { + describe('clearStateHandler', () => { + it('has the expected shape', () => { + expect(clearStateHandler).toMatchObject({ + methodNames: ['snap_clearState'], + implementation: expect.any(Function), + hookNames: { + clearSnapState: true, + hasPermission: true, + }, + }); + }); + }); + + describe('implementation', () => { + it('returns the result from the `clearSnapState` hook', async () => { + const { implementation } = clearStateHandler; + + const clearSnapState = jest.fn().mockReturnValue(null); + const hasPermission = jest.fn().mockReturnValue(true); + + const hooks = { + clearSnapState, + hasPermission, + }; + + const engine = new JsonRpcEngine(); + + engine.push((request, response, next, end) => { + const result = implementation( + request as JsonRpcRequest, + response as PendingJsonRpcResponse, + next, + end, + hooks, + ); + + result?.catch(end); + }); + + const response = await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: 'snap_clearState', + params: {}, + }); + + expect(clearSnapState).toHaveBeenCalledWith(true); + expect(response).toStrictEqual({ + jsonrpc: '2.0', + id: 1, + result: null, + }); + }); + + it('clears unencrypted state if specified', async () => { + const { implementation } = clearStateHandler; + + const clearSnapState = jest.fn().mockReturnValue(null); + const hasPermission = jest.fn().mockReturnValue(true); + + const hooks = { + clearSnapState, + hasPermission, + }; + + const engine = new JsonRpcEngine(); + + engine.push((request, response, next, end) => { + const result = implementation( + request as JsonRpcRequest, + response as PendingJsonRpcResponse, + next, + end, + hooks, + ); + + result?.catch(end); + }); + + const response = await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: 'snap_clearState', + params: { + encrypted: false, + }, + }); + + expect(clearSnapState).toHaveBeenCalledWith(false); + expect(response).toStrictEqual({ + jsonrpc: '2.0', + id: 1, + result: null, + }); + }); + + it('throws if the requesting origin does not have the required permission', async () => { + const { implementation } = clearStateHandler; + + const clearSnapState = jest.fn(); + const hasPermission = jest.fn().mockReturnValue(false); + + const hooks = { + clearSnapState, + hasPermission, + }; + + const engine = new JsonRpcEngine(); + + engine.push((request, response, next, end) => { + const result = implementation( + request as JsonRpcRequest, + response as PendingJsonRpcResponse, + next, + end, + hooks, + ); + + result?.catch(end); + }); + + const response = await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: 'snap_clearState', + params: {}, + }); + + expect(clearSnapState).not.toHaveBeenCalled(); + expect(response).toStrictEqual({ + jsonrpc: '2.0', + id: 1, + error: { + code: errorCodes.provider.unauthorized, + message: + 'The requested account and/or method has not been authorized by the user.', + stack: expect.any(String), + }, + }); + }); + + it('throws if the parameters are invalid', async () => { + const { implementation } = clearStateHandler; + + const clearSnapState = jest.fn(); + const hasPermission = jest.fn().mockReturnValue(true); + + const hooks = { + clearSnapState, + hasPermission, + }; + + const engine = new JsonRpcEngine(); + + engine.push((request, response, next, end) => { + const result = implementation( + request as JsonRpcRequest, + response as PendingJsonRpcResponse, + next, + end, + hooks, + ); + + result?.catch(end); + }); + + const response = await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: 'snap_clearState', + params: { + encrypted: 'foo', + }, + }); + + expect(response).toStrictEqual({ + jsonrpc: '2.0', + id: 1, + error: { + code: errorCodes.rpc.invalidParams, + message: + 'Invalid params: At path: encrypted -- Expected a value of type `boolean`, but received: `"foo"`.', + stack: expect.any(String), + }, + }); + }); + }); +}); diff --git a/packages/snaps-rpc-methods/src/permitted/clearState.ts b/packages/snaps-rpc-methods/src/permitted/clearState.ts new file mode 100644 index 0000000000..0c4738ca66 --- /dev/null +++ b/packages/snaps-rpc-methods/src/permitted/clearState.ts @@ -0,0 +1,120 @@ +import type { JsonRpcEngineEndCallback } from '@metamask/json-rpc-engine'; +import type { PermittedHandlerExport } from '@metamask/permission-controller'; +import { providerErrors, rpcErrors } from '@metamask/rpc-errors'; +import type { ClearStateParams, ClearStateResult } from '@metamask/snaps-sdk'; +import { type InferMatching } from '@metamask/snaps-utils'; +import { + boolean, + create, + object, + optional, + StructError, +} from '@metamask/superstruct'; +import type { PendingJsonRpcResponse, JsonRpcRequest } from '@metamask/utils'; + +import { manageStateBuilder } from '../restricted/manageState'; +import type { MethodHooksObject } from '../utils'; + +const hookNames: MethodHooksObject = { + clearSnapState: true, + hasPermission: true, +}; + +/** + * `snap_clearState` clears the state of the Snap. + */ +export const clearStateHandler: PermittedHandlerExport< + ClearStateHooks, + ClearStateParameters, + ClearStateResult +> = { + methodNames: ['snap_clearState'], + implementation: clearStateImplementation, + hookNames, +}; + +export type ClearStateHooks = { + /** + * A function that clears the state of the requesting Snap. + */ + clearSnapState: (encrypted: boolean) => void; + + /** + * Check if 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; +}; + +const ClearStateParametersStruct = object({ + encrypted: optional(boolean()), +}); + +export type ClearStateParameters = InferMatching< + typeof ClearStateParametersStruct, + ClearStateParams +>; + +/** + * The `snap_clearState` method implementation. + * + * @param request - The JSON-RPC request object. + * @param response - The JSON-RPC response object. + * @param _next - The `json-rpc-engine` "next" callback. Not used by this + * function. + * @param end - The `json-rpc-engine` "end" callback. + * @param hooks - The RPC method hooks. + * @param hooks.clearSnapState - A function that clears the state of the + * requesting Snap. + * @param hooks.hasPermission - Check whether a given origin has a given + * permission. + * @returns Nothing. + */ +async function clearStateImplementation( + request: JsonRpcRequest, + response: PendingJsonRpcResponse, + _next: unknown, + end: JsonRpcEngineEndCallback, + { clearSnapState, hasPermission }: ClearStateHooks, +): Promise { + const { params } = request; + + if (!hasPermission(manageStateBuilder.targetName)) { + return end(providerErrors.unauthorized()); + } + + try { + const validatedParams = getValidatedParams(params); + const { encrypted = true } = validatedParams; + + clearSnapState(encrypted); + response.result = null; + } catch (error) { + return end(error); + } + + return end(); +} + +/** + * Validate the parameters of the `snap_clearState` method. + * + * @param params - The parameters to validate. + * @returns The validated parameters. + */ +function getValidatedParams(params?: unknown) { + try { + return create(params, ClearStateParametersStruct); + } catch (error) { + if (error instanceof StructError) { + throw rpcErrors.invalidParams({ + message: `Invalid params: ${error.message}.`, + }); + } + + /* istanbul ignore next */ + throw rpcErrors.internal(); + } +} diff --git a/packages/snaps-rpc-methods/src/permitted/getState.test.ts b/packages/snaps-rpc-methods/src/permitted/getState.test.ts new file mode 100644 index 0000000000..7be63e258f --- /dev/null +++ b/packages/snaps-rpc-methods/src/permitted/getState.test.ts @@ -0,0 +1,305 @@ +import { JsonRpcEngine } from '@metamask/json-rpc-engine'; +import { errorCodes } from '@metamask/rpc-errors'; +import type { GetStateResult } from '@metamask/snaps-sdk'; +import type { JsonRpcRequest, PendingJsonRpcResponse } from '@metamask/utils'; + +import type { GetStateParameters } from './getState'; +import { get, getStateHandler } from './getState'; + +describe('snap_getState', () => { + describe('getStateHandler', () => { + it('has the expected shape', () => { + expect(getStateHandler).toMatchObject({ + methodNames: ['snap_getState'], + implementation: expect.any(Function), + hookNames: { + getSnapState: true, + hasPermission: true, + }, + }); + }); + }); + + describe('implementation', () => { + it('returns the encrypted state', async () => { + const { implementation } = getStateHandler; + + const getSnapState = jest.fn().mockReturnValue({ + foo: 'bar', + }); + + const getUnlockPromise = jest.fn().mockResolvedValue(undefined); + const hasPermission = jest.fn().mockReturnValue(true); + + const hooks = { + getSnapState, + getUnlockPromise, + hasPermission, + }; + + const engine = new JsonRpcEngine(); + + engine.push((request, response, next, end) => { + const result = implementation( + request as JsonRpcRequest, + response as PendingJsonRpcResponse, + next, + end, + hooks, + ); + + result?.catch(end); + }); + + const response = await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: 'snap_getState', + params: { + key: 'foo', + }, + }); + + expect(getSnapState).toHaveBeenCalledWith(true); + expect(response).toStrictEqual({ + jsonrpc: '2.0', + id: 1, + result: 'bar', + }); + }); + + it('returns the entire state if no key is specified', async () => { + const { implementation } = getStateHandler; + + const getSnapState = jest.fn().mockReturnValue({ + foo: 'bar', + }); + + const getUnlockPromise = jest.fn().mockResolvedValue(undefined); + const hasPermission = jest.fn().mockReturnValue(true); + + const hooks = { + getSnapState, + getUnlockPromise, + hasPermission, + }; + + const engine = new JsonRpcEngine(); + + engine.push((request, response, next, end) => { + const result = implementation( + request as JsonRpcRequest, + response as PendingJsonRpcResponse, + next, + end, + hooks, + ); + + result?.catch(end); + }); + + const response = await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: 'snap_getState', + params: {}, + }); + + expect(getSnapState).toHaveBeenCalledWith(true); + expect(response).toStrictEqual({ + jsonrpc: '2.0', + id: 1, + result: { + foo: 'bar', + }, + }); + }); + + it('returns the unencrypted state', async () => { + const { implementation } = getStateHandler; + + const getSnapState = jest.fn().mockReturnValue({ + foo: 'bar', + }); + + const getUnlockPromise = jest.fn().mockResolvedValue(undefined); + const hasPermission = jest.fn().mockReturnValue(true); + + const hooks = { + getSnapState, + getUnlockPromise, + hasPermission, + }; + + const engine = new JsonRpcEngine(); + + engine.push((request, response, next, end) => { + const result = implementation( + request as JsonRpcRequest, + response as PendingJsonRpcResponse, + next, + end, + hooks, + ); + + result?.catch(end); + }); + + const response = await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: 'snap_getState', + params: { + key: 'foo', + encrypted: false, + }, + }); + + expect(getSnapState).toHaveBeenCalledWith(false); + expect(response).toStrictEqual({ + jsonrpc: '2.0', + id: 1, + result: 'bar', + }); + }); + + it('throws if the requesting origin does not have the required permission', async () => { + const { implementation } = getStateHandler; + + const getSnapState = jest.fn().mockReturnValue({ + foo: 'bar', + }); + + const getUnlockPromise = jest.fn().mockResolvedValue(undefined); + const hasPermission = jest.fn().mockReturnValue(false); + + const hooks = { + getSnapState, + getUnlockPromise, + hasPermission, + }; + + const engine = new JsonRpcEngine(); + + engine.push((request, response, next, end) => { + const result = implementation( + request as JsonRpcRequest, + response as PendingJsonRpcResponse, + next, + end, + hooks, + ); + + result?.catch(end); + }); + + const response = await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: 'snap_getState', + params: {}, + }); + + expect(getSnapState).not.toHaveBeenCalled(); + expect(response).toStrictEqual({ + jsonrpc: '2.0', + id: 1, + error: { + code: errorCodes.provider.unauthorized, + message: + 'The requested account and/or method has not been authorized by the user.', + stack: expect.any(String), + }, + }); + }); + + it('throws if the parameters are invalid', async () => { + const { implementation } = getStateHandler; + + const getSnapState = jest.fn().mockReturnValue({ + foo: 'bar', + }); + + const getUnlockPromise = jest.fn().mockResolvedValue(undefined); + const hasPermission = jest.fn().mockReturnValue(true); + + const hooks = { + getSnapState, + getUnlockPromise, + hasPermission, + }; + + const engine = new JsonRpcEngine(); + + engine.push((request, response, next, end) => { + const result = implementation( + request as JsonRpcRequest, + response as PendingJsonRpcResponse, + next, + end, + hooks, + ); + + result?.catch(end); + }); + + const response = await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: 'snap_getState', + params: { + encrypted: 'foo', + }, + }); + + expect(response).toStrictEqual({ + jsonrpc: '2.0', + id: 1, + error: { + code: errorCodes.rpc.invalidParams, + message: + 'Invalid params: At path: encrypted -- Expected a value of type `boolean`, but received: `"foo"`.', + stack: expect.any(String), + }, + }); + }); + }); +}); + +describe('get', () => { + const object = { + a: { + b: { + c: 'value', + }, + }, + }; + + it('returns the value of the key', () => { + expect(get(object, 'a.b.c')).toBe('value'); + }); + + it('returns `null` if the object is `null`', () => { + expect(get(null, '')).toBeNull(); + }); + + it('returns `null` if the key does not exist', () => { + expect(get(object, 'a.b.d')).toBeNull(); + }); + + it('returns `null` if the parent key is not an object', () => { + expect(get(object, 'a.b.c.d')).toBeNull(); + }); + + it('throws an error if the key is a prototype pollution attempt', () => { + expect(() => get(object, '__proto__.polluted')).toThrow( + 'Invalid params: Key contains forbidden characters.', + ); + }); + + it('throws an error if the key is a constructor pollution attempt', () => { + expect(() => get(object, 'constructor.polluted')).toThrow( + 'Invalid params: Key contains forbidden characters.', + ); + }); +}); diff --git a/packages/snaps-rpc-methods/src/permitted/getState.ts b/packages/snaps-rpc-methods/src/permitted/getState.ts new file mode 100644 index 0000000000..92603d0232 --- /dev/null +++ b/packages/snaps-rpc-methods/src/permitted/getState.ts @@ -0,0 +1,186 @@ +import type { JsonRpcEngineEndCallback } from '@metamask/json-rpc-engine'; +import type { PermittedHandlerExport } from '@metamask/permission-controller'; +import { providerErrors, rpcErrors } from '@metamask/rpc-errors'; +import type { GetStateParams, GetStateResult } from '@metamask/snaps-sdk'; +import { type InferMatching } from '@metamask/snaps-utils'; +import { + boolean, + create, + object, + optional, + StructError, +} from '@metamask/superstruct'; +import type { PendingJsonRpcResponse, JsonRpcRequest } from '@metamask/utils'; +import { hasProperty, isObject, type Json } from '@metamask/utils'; + +import { manageStateBuilder } from '../restricted/manageState'; +import type { MethodHooksObject } from '../utils'; +import { FORBIDDEN_KEYS, StateKeyStruct } from '../utils'; + +const hookNames: MethodHooksObject = { + hasPermission: true, + getSnapState: true, + getUnlockPromise: true, +}; + +/** + * `snap_getState` gets the state of the Snap. + */ +export const getStateHandler: PermittedHandlerExport< + GetStateHooks, + GetStateParameters, + GetStateResult +> = { + methodNames: ['snap_getState'], + implementation: getStateImplementation, + hookNames, +}; + +export type GetStateHooks = { + /** + * Check if 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; + + /** + * Get the state of the requesting Snap. + * + * @returns The current state of the Snap. + */ + getSnapState: (encrypted: boolean) => Promise>; + + /** + * Wait for the extension to be unlocked. + * + * @returns A promise that resolves once the extension is unlocked. + */ + getUnlockPromise: (shouldShowUnlockRequest: boolean) => Promise; +}; + +const GetStateParametersStruct = object({ + key: optional(StateKeyStruct), + encrypted: optional(boolean()), +}); + +export type GetStateParameters = InferMatching< + typeof GetStateParametersStruct, + GetStateParams +>; + +/** + * The `snap_getState` method implementation. + * + * @param request - The JSON-RPC request object. + * @param response - The JSON-RPC response object. + * @param _next - The `json-rpc-engine` "next" callback. Not used by this + * function. + * @param end - The `json-rpc-engine` "end" callback. + * @param hooks - The RPC method hooks. + * @param hooks.hasPermission - Check whether a given origin has a given + * permission. + * @param hooks.getSnapState - Get the state of the requesting Snap. + * @param hooks.getUnlockPromise - Wait for the extension to be unlocked. + * @returns Nothing. + */ +async function getStateImplementation( + request: JsonRpcRequest, + response: PendingJsonRpcResponse, + _next: unknown, + end: JsonRpcEngineEndCallback, + { hasPermission, getSnapState, getUnlockPromise }: GetStateHooks, +): Promise { + const { params } = request; + + if (!hasPermission(manageStateBuilder.targetName)) { + return end(providerErrors.unauthorized()); + } + + try { + const validatedParams = getValidatedParams(params); + const { key, encrypted = true } = validatedParams; + + if (encrypted) { + await getUnlockPromise(true); + } + + const state = await getSnapState(encrypted); + response.result = get(state, key); + } catch (error) { + return end(error); + } + + return end(); +} + +/** + * Validate the parameters of the `snap_getState` method. + * + * @param params - The parameters to validate. + * @returns The validated parameters. + */ +function getValidatedParams(params?: unknown) { + try { + return create(params, GetStateParametersStruct); + } catch (error) { + if (error instanceof StructError) { + throw rpcErrors.invalidParams({ + message: `Invalid params: ${error.message}.`, + }); + } + + /* istanbul ignore next */ + throw rpcErrors.internal(); + } +} + +/** + * Get the value of a key in an object. The key may contain Lodash-style path + * syntax, e.g., `a.b.c` (with the exception of array syntax). If the key does + * not exist, `null` is returned. + * + * This is a simplified version of Lodash's `get` function, but Lodash doesn't + * seem to be maintained anymore, so we're using our own implementation. + * + * @param value - The object to get the key from. + * @param key - The key to get. + * @returns The value of the key in the object, or `null` if the key does not + * exist. + */ +export function get( + value: Record | null, + key?: string | undefined, +): Json { + if (key === undefined) { + return value; + } + + const keys = key.split('.'); + let result: Json = value; + + // Intentionally using a classic for loop here for performance reasons. + // eslint-disable-next-line @typescript-eslint/prefer-for-of + for (let i = 0; i < keys.length; i++) { + const currentKey = keys[i]; + if (FORBIDDEN_KEYS.includes(currentKey)) { + throw rpcErrors.invalidParams( + 'Invalid params: Key contains forbidden characters.', + ); + } + + if (isObject(result)) { + if (!hasProperty(result, currentKey)) { + return null; + } + + result = result[currentKey]; + continue; + } + + return null; + } + + return result; +} diff --git a/packages/snaps-rpc-methods/src/permitted/handlers.ts b/packages/snaps-rpc-methods/src/permitted/handlers.ts index 83730b1a15..5e0c6f201d 100644 --- a/packages/snaps-rpc-methods/src/permitted/handlers.ts +++ b/packages/snaps-rpc-methods/src/permitted/handlers.ts @@ -1,3 +1,4 @@ +import { clearStateHandler } from './clearState'; import { createInterfaceHandler } from './createInterface'; import { providerRequestHandler } from './experimentalProviderRequest'; import { getAllSnapsHandler } from './getAllSnaps'; @@ -7,10 +8,12 @@ import { getFileHandler } from './getFile'; import { getInterfaceContextHandler } from './getInterfaceContext'; import { getInterfaceStateHandler } from './getInterfaceState'; import { getSnapsHandler } from './getSnaps'; +import { getStateHandler } from './getState'; import { invokeKeyringHandler } from './invokeKeyring'; import { invokeSnapSugarHandler } from './invokeSnapSugar'; import { requestSnapsHandler } from './requestSnaps'; import { resolveInterfaceHandler } from './resolveInterface'; +import { setStateHandler } from './setState'; import { updateInterfaceHandler } from './updateInterface'; /* eslint-disable @typescript-eslint/naming-convention */ @@ -20,8 +23,10 @@ export const methodHandlers = { wallet_requestSnaps: requestSnapsHandler, wallet_invokeSnap: invokeSnapSugarHandler, wallet_invokeKeyring: invokeKeyringHandler, + snap_clearState: clearStateHandler, snap_getClientStatus: getClientStatusHandler, snap_getFile: getFileHandler, + snap_getState: getStateHandler, snap_createInterface: createInterfaceHandler, snap_updateInterface: updateInterfaceHandler, snap_getInterfaceState: getInterfaceStateHandler, @@ -29,6 +34,7 @@ export const methodHandlers = { snap_resolveInterface: resolveInterfaceHandler, snap_getCurrencyRate: getCurrencyRateHandler, snap_experimentalProviderRequest: providerRequestHandler, + snap_setState: setStateHandler, }; /* eslint-enable @typescript-eslint/naming-convention */ diff --git a/packages/snaps-rpc-methods/src/permitted/index.ts b/packages/snaps-rpc-methods/src/permitted/index.ts index 5aa676fce3..47febaba09 100644 --- a/packages/snaps-rpc-methods/src/permitted/index.ts +++ b/packages/snaps-rpc-methods/src/permitted/index.ts @@ -1,3 +1,4 @@ +import type { ClearStateHooks } from './clearState'; import type { CreateInterfaceMethodHooks } from './createInterface'; import type { ProviderRequestMethodHooks } from './experimentalProviderRequest'; import type { GetAllSnapsHooks } from './getAllSnaps'; @@ -5,20 +6,25 @@ import type { GetClientStatusHooks } from './getClientStatus'; import type { GetCurrencyRateMethodHooks } from './getCurrencyRate'; import type { GetInterfaceStateMethodHooks } from './getInterfaceState'; import type { GetSnapsHooks } from './getSnaps'; +import type { GetStateHooks } from './getState'; import type { RequestSnapsHooks } from './requestSnaps'; import type { ResolveInterfaceMethodHooks } from './resolveInterface'; +import type { SetStateHooks } from './setState'; import type { UpdateInterfaceMethodHooks } from './updateInterface'; -export type PermittedRpcMethodHooks = GetAllSnapsHooks & +export type PermittedRpcMethodHooks = ClearStateHooks & + GetAllSnapsHooks & GetClientStatusHooks & GetSnapsHooks & + GetStateHooks & RequestSnapsHooks & CreateInterfaceMethodHooks & UpdateInterfaceMethodHooks & GetInterfaceStateMethodHooks & ResolveInterfaceMethodHooks & GetCurrencyRateMethodHooks & - ProviderRequestMethodHooks; + ProviderRequestMethodHooks & + SetStateHooks; export * from './handlers'; export * from './middleware'; diff --git a/packages/snaps-rpc-methods/src/permitted/setState.test.ts b/packages/snaps-rpc-methods/src/permitted/setState.test.ts new file mode 100644 index 0000000000..759722d9bf --- /dev/null +++ b/packages/snaps-rpc-methods/src/permitted/setState.test.ts @@ -0,0 +1,511 @@ +import { JsonRpcEngine } from '@metamask/json-rpc-engine'; +import { errorCodes } from '@metamask/rpc-errors'; +import type { SetStateResult } from '@metamask/snaps-sdk'; +import type { JsonRpcRequest, PendingJsonRpcResponse } from '@metamask/utils'; + +import { setStateHandler, type SetStateParameters, set } from './setState'; + +describe('snap_setState', () => { + describe('setStateHandler', () => { + it('has the expected shape', () => { + expect(setStateHandler).toMatchObject({ + methodNames: ['snap_setState'], + implementation: expect.any(Function), + hookNames: { + getSnapState: true, + hasPermission: true, + }, + }); + }); + }); + + describe('implementation', () => { + it('sets the encrypted state', async () => { + const { implementation } = setStateHandler; + + const getSnapState = jest.fn().mockReturnValue({ + foo: 'bar', + }); + + const updateSnapState = jest.fn().mockReturnValue(null); + const getUnlockPromise = jest.fn().mockResolvedValue(undefined); + const hasPermission = jest.fn().mockReturnValue(true); + + const hooks = { + getSnapState, + updateSnapState, + getUnlockPromise, + hasPermission, + }; + + const engine = new JsonRpcEngine(); + + engine.push((request, response, next, end) => { + const result = implementation( + request as JsonRpcRequest, + response as PendingJsonRpcResponse, + next, + end, + hooks, + ); + + result?.catch(end); + }); + + const response = await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: 'snap_setState', + params: { + key: 'foo', + value: 'baz', + }, + }); + + expect(getUnlockPromise).toHaveBeenCalled(); + expect(getSnapState).toHaveBeenCalledWith(true); + expect(updateSnapState).toHaveBeenCalledWith({ foo: 'baz' }, true); + + expect(response).toStrictEqual({ + jsonrpc: '2.0', + id: 1, + result: null, + }); + }); + + it('sets the entire state if no key is specified', async () => { + const { implementation } = setStateHandler; + + const getSnapState = jest.fn().mockReturnValue({ + foo: 'bar', + }); + + const updateSnapState = jest.fn().mockReturnValue(null); + const getUnlockPromise = jest.fn().mockResolvedValue(undefined); + const hasPermission = jest.fn().mockReturnValue(true); + + const hooks = { + getSnapState, + updateSnapState, + getUnlockPromise, + hasPermission, + }; + + const engine = new JsonRpcEngine(); + + engine.push((request, response, next, end) => { + const result = implementation( + request as JsonRpcRequest, + response as PendingJsonRpcResponse, + next, + end, + hooks, + ); + + result?.catch(end); + }); + + const response = await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: 'snap_setState', + params: { + value: { + foo: 'baz', + }, + }, + }); + + expect(getUnlockPromise).toHaveBeenCalled(); + expect(getSnapState).not.toHaveBeenCalled(); + expect(updateSnapState).toHaveBeenCalledWith({ foo: 'baz' }, true); + + expect(response).toStrictEqual({ + jsonrpc: '2.0', + id: 1, + result: null, + }); + }); + + it('sets the unencrypted state', async () => { + const { implementation } = setStateHandler; + + const getSnapState = jest.fn().mockReturnValue({ + foo: 'bar', + }); + + const updateSnapState = jest.fn().mockReturnValue(null); + const getUnlockPromise = jest.fn().mockResolvedValue(undefined); + const hasPermission = jest.fn().mockReturnValue(true); + + const hooks = { + getSnapState, + updateSnapState, + getUnlockPromise, + hasPermission, + }; + + const engine = new JsonRpcEngine(); + + engine.push((request, response, next, end) => { + const result = implementation( + request as JsonRpcRequest, + response as PendingJsonRpcResponse, + next, + end, + hooks, + ); + + result?.catch(end); + }); + + const response = await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: 'snap_setState', + params: { + key: 'foo', + value: 'baz', + encrypted: false, + }, + }); + + expect(getUnlockPromise).not.toHaveBeenCalled(); + expect(getSnapState).toHaveBeenCalledWith(false); + expect(updateSnapState).toHaveBeenCalledWith( + { + foo: 'baz', + }, + false, + ); + + expect(response).toStrictEqual({ + jsonrpc: '2.0', + id: 1, + result: null, + }); + }); + + it('throws if the requesting origin does not have the required permission', async () => { + const { implementation } = setStateHandler; + + const getSnapState = jest.fn().mockReturnValue({ + foo: 'bar', + }); + + const updateSnapState = jest.fn().mockReturnValue(null); + const getUnlockPromise = jest.fn().mockResolvedValue(undefined); + const hasPermission = jest.fn().mockReturnValue(false); + + const hooks = { + getSnapState, + updateSnapState, + getUnlockPromise, + hasPermission, + }; + + const engine = new JsonRpcEngine(); + + engine.push((request, response, next, end) => { + const result = implementation( + request as JsonRpcRequest, + response as PendingJsonRpcResponse, + next, + end, + hooks, + ); + + result?.catch(end); + }); + + const response = await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: 'snap_setState', + params: {}, + }); + + expect(updateSnapState).not.toHaveBeenCalled(); + expect(response).toStrictEqual({ + jsonrpc: '2.0', + id: 1, + error: { + code: errorCodes.provider.unauthorized, + message: + 'The requested account and/or method has not been authorized by the user.', + stack: expect.any(String), + }, + }); + }); + + it('throws if the parameters are invalid', async () => { + const { implementation } = setStateHandler; + + const getSnapState = jest.fn().mockReturnValue({ + foo: 'bar', + }); + + const updateSnapState = jest.fn().mockReturnValue(null); + const getUnlockPromise = jest.fn().mockResolvedValue(undefined); + const hasPermission = jest.fn().mockReturnValue(true); + + const hooks = { + getSnapState, + updateSnapState, + getUnlockPromise, + hasPermission, + }; + + const engine = new JsonRpcEngine(); + + engine.push((request, response, next, end) => { + const result = implementation( + request as JsonRpcRequest, + response as PendingJsonRpcResponse, + next, + end, + hooks, + ); + + result?.catch(end); + }); + + const response = await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: 'snap_setState', + params: {}, + }); + + expect(response).toStrictEqual({ + jsonrpc: '2.0', + id: 1, + error: { + code: errorCodes.rpc.internal, + message: 'Internal JSON-RPC error.', + stack: expect.any(String), + }, + }); + }); + + it('throws if the encrypted parameter is invalid', async () => { + const { implementation } = setStateHandler; + + const getSnapState = jest.fn().mockReturnValue({ + foo: 'bar', + }); + + const updateSnapState = jest.fn().mockReturnValue(null); + const getUnlockPromise = jest.fn().mockResolvedValue(undefined); + const hasPermission = jest.fn().mockReturnValue(true); + + const hooks = { + getSnapState, + updateSnapState, + getUnlockPromise, + hasPermission, + }; + + const engine = new JsonRpcEngine(); + + engine.push((request, response, next, end) => { + const result = implementation( + request as JsonRpcRequest, + response as PendingJsonRpcResponse, + next, + end, + hooks, + ); + + result?.catch(end); + }); + + const response = await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: 'snap_setState', + params: { + key: 'foo', + value: 'bar', + encrypted: 'baz', + }, + }); + + expect(response).toStrictEqual({ + jsonrpc: '2.0', + id: 1, + error: { + code: errorCodes.rpc.invalidParams, + message: + 'Invalid params: At path: encrypted -- Expected a value of type `boolean`, but received: `"baz"`.', + stack: expect.any(String), + }, + }); + }); + + it('throws if `key` is not provided and `value` is not an object', async () => { + const { implementation } = setStateHandler; + + const getSnapState = jest.fn().mockReturnValue({ + foo: 'bar', + }); + + const updateSnapState = jest.fn().mockReturnValue(null); + const getUnlockPromise = jest.fn().mockResolvedValue(undefined); + const hasPermission = jest.fn().mockReturnValue(true); + + const hooks = { + getSnapState, + updateSnapState, + getUnlockPromise, + hasPermission, + }; + + const engine = new JsonRpcEngine(); + + engine.push((request, response, next, end) => { + const result = implementation( + request as JsonRpcRequest, + response as PendingJsonRpcResponse, + next, + end, + hooks, + ); + + result?.catch(end); + }); + + const response = await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: 'snap_setState', + params: { + value: 'foo', + }, + }); + + expect(response).toStrictEqual({ + jsonrpc: '2.0', + id: 1, + error: { + code: errorCodes.rpc.invalidParams, + message: + 'Invalid params: Value must be an object if key is not provided.', + stack: expect.any(String), + }, + }); + }); + }); +}); + +describe('set', () => { + it('sets the state in an empty object', () => { + const object = {}; + + expect(set(object, 'key', 'value')).toStrictEqual({ + key: 'value', + }); + }); + + it('sets the state if the current state is `null`', () => { + const object = null; + + expect(set(object, 'key', 'value')).toStrictEqual({ + key: 'value', + }); + }); + + it('sets the state in an empty object with a nested key', () => { + const object = {}; + + expect(set(object, 'nested.key', 'newValue')).toStrictEqual({ + nested: { + key: 'newValue', + }, + }); + }); + + it('sets the state in an existing object', () => { + const object = { + key: 'oldValue', + }; + + expect(set(object, 'key', 'newValue')).toStrictEqual({ + key: 'newValue', + }); + }); + + it('sets the state in an existing object with a nested key', () => { + const object = { + nested: { + key: 'oldValue', + }, + }; + + expect(set(object, 'nested.key', 'newValue')).toStrictEqual({ + nested: { + key: 'newValue', + }, + }); + }); + + it('sets the state in an existing object with a nested key that does not exist', () => { + const object = { + nested: {}, + }; + + expect(set(object, 'nested.key', 'newValue')).toStrictEqual({ + nested: { + key: 'newValue', + }, + }); + }); + + it('overwrites the nested state in an existing object', () => { + const object = { + nested: { + key: 'oldValue', + }, + }; + + expect(set(object, 'nested', { foo: 'bar' })).toStrictEqual({ + nested: { + foo: 'bar', + }, + }); + }); + + it('allows overwriting if a parent key is `null`', () => { + const object = { + nested: null, + }; + + expect(set(object, 'nested.key', 'newValue')).toStrictEqual({ + nested: { + key: 'newValue', + }, + }); + }); + + it('throws if a parent key is not an object', () => { + const object = { + nested: 'value', + }; + + expect(() => set(object, 'nested.key', 'newValue')).toThrow( + 'Invalid params: Cannot overwrite non-object value.', + ); + }); + + it('throws an error if the key is a prototype pollution attempt', () => { + expect(() => set({}, '__proto__.polluted', 'value')).toThrow( + 'Invalid params: Key contains forbidden characters.', + ); + }); + + it('throws an error if the key is a constructor pollution attempt', () => { + expect(() => set({}, 'constructor.polluted', 'value')).toThrow( + 'Invalid params: Key contains forbidden characters.', + ); + }); +}); diff --git a/packages/snaps-rpc-methods/src/permitted/setState.ts b/packages/snaps-rpc-methods/src/permitted/setState.ts new file mode 100644 index 0000000000..5227d69391 --- /dev/null +++ b/packages/snaps-rpc-methods/src/permitted/setState.ts @@ -0,0 +1,260 @@ +import type { JsonRpcEngineEndCallback } from '@metamask/json-rpc-engine'; +import type { PermittedHandlerExport } from '@metamask/permission-controller'; +import { providerErrors, rpcErrors } from '@metamask/rpc-errors'; +import type { SetStateParams, SetStateResult } from '@metamask/snaps-sdk'; +import type { JsonObject } from '@metamask/snaps-sdk/jsx'; +import { type InferMatching } from '@metamask/snaps-utils'; +import { + boolean, + create, + object as objectStruct, + optional, + StructError, +} from '@metamask/superstruct'; +import type { PendingJsonRpcResponse, JsonRpcRequest } from '@metamask/utils'; +import { + hasProperty, + isObject, + assert, + JsonStruct, + type Json, +} from '@metamask/utils'; + +import { manageStateBuilder } from '../restricted/manageState'; +import type { MethodHooksObject } from '../utils'; +import { FORBIDDEN_KEYS, StateKeyStruct } from '../utils'; + +const hookNames: MethodHooksObject = { + hasPermission: true, + getSnapState: true, + getUnlockPromise: true, + updateSnapState: true, +}; + +/** + * `snap_setState` sets the state of the Snap. + */ +export const setStateHandler: PermittedHandlerExport< + SetStateHooks, + SetStateParameters, + SetStateResult +> = { + methodNames: ['snap_setState'], + implementation: setStateImplementation, + hookNames, +}; + +export type SetStateHooks = { + /** + * Check if 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; + + /** + * Get the state of the requesting Snap. + * + * @param encrypted - Whether the state is encrypted. + * @returns The current state of the Snap. + */ + getSnapState: (encrypted: boolean) => Promise>; + + /** + * Wait for the extension to be unlocked. + * + * @returns A promise that resolves once the extension is unlocked. + */ + getUnlockPromise: (shouldShowUnlockRequest: boolean) => Promise; + + /** + * Update the state of the requesting Snap. + * + * @param newState - The new state of the Snap. + * @param encrypted - Whether the state should be encrypted. + */ + updateSnapState: ( + newState: Record, + encrypted: boolean, + ) => Promise; +}; + +const SetStateParametersStruct = objectStruct({ + key: optional(StateKeyStruct), + value: JsonStruct, + encrypted: optional(boolean()), +}); + +export type SetStateParameters = InferMatching< + typeof SetStateParametersStruct, + SetStateParams +>; + +/** + * The `snap_setState` method implementation. + * + * @param request - The JSON-RPC request object. + * @param response - The JSON-RPC response object. + * @param _next - The `json-rpc-engine` "next" callback. Not used by this + * function. + * @param end - The `json-rpc-engine` "end" callback. + * @param hooks - The RPC method hooks. + * @param hooks.hasPermission - Check whether a given origin has a given + * permission. + * @param hooks.getSnapState - Get the state of the requesting Snap. + * @param hooks.getUnlockPromise - Wait for the extension to be unlocked. + * @param hooks.updateSnapState - Update the state of the requesting Snap. + * @returns Nothing. + */ +async function setStateImplementation( + request: JsonRpcRequest, + response: PendingJsonRpcResponse, + _next: unknown, + end: JsonRpcEngineEndCallback, + { + hasPermission, + getSnapState, + getUnlockPromise, + updateSnapState, + }: SetStateHooks, +): Promise { + const { params } = request; + + if (!hasPermission(manageStateBuilder.targetName)) { + return end(providerErrors.unauthorized()); + } + + try { + const validatedParams = getValidatedParams(params); + const { key, value, encrypted = true } = validatedParams; + + if (key === undefined && !isObject(value)) { + return end( + rpcErrors.invalidParams( + 'Invalid params: Value must be an object if key is not provided.', + ), + ); + } + + if (encrypted) { + await getUnlockPromise(true); + } + + const newState = await getNewState(key, value, encrypted, getSnapState); + await updateSnapState(newState, encrypted); + response.result = null; + } catch (error) { + return end(error); + } + + return end(); +} + +/** + * Validate the parameters of the `snap_setState` method. + * + * @param params - The parameters to validate. + * @returns The validated parameters. + */ +function getValidatedParams(params?: unknown) { + try { + return create(params, SetStateParametersStruct); + } catch (error) { + if (error instanceof StructError) { + throw rpcErrors.invalidParams({ + message: `Invalid params: ${error.message}.`, + }); + } + + /* istanbul ignore next */ + throw rpcErrors.internal(); + } +} + +/** + * Get the new state of the Snap. + * + * If the key is `undefined`, the value is expected to be an object. In this + * case, the value is returned as the new state. + * + * If the key is not `undefined`, the value is set in the state at the key. If + * the key does not exist, it is created (and any missing intermediate keys are + * created as well). + * + * @param key - The key to set. + * @param value - The value to set the key to. + * @param encrypted - Whether the state is encrypted. + * @param getSnapState - The `getSnapState` hook. + * @returns The new state of the Snap. + */ +async function getNewState( + key: string | undefined, + value: Json, + encrypted: boolean, + getSnapState: SetStateHooks['getSnapState'], +) { + if (key === undefined) { + assert(isObject(value)); + return value; + } + + const state = await getSnapState(encrypted); + return set(state, key, value); +} + +/** + * Set the value of a key in an object. The key may contain Lodash-style path + * syntax, e.g., `a.b.c` (with the exception of array syntax). If the key does + * not exist, it is created (and any missing intermediate keys are created as + * well). + * + * This is a simplified version of Lodash's `set` function, but Lodash doesn't + * seem to be maintained anymore, so we're using our own implementation. + * + * @param object - The object to get the key from. + * @param key - The key to set. + * @param value - The value to set the key to. + * @returns The new object with the key set to the value. + */ +export function set( + // eslint-disable-next-line @typescript-eslint/default-param-last + object: Record | null, + key: string, + value: Json, +): JsonObject { + const keys = key.split('.'); + const requiredObject = object ?? {}; + let currentObject: Record = requiredObject; + + for (let i = 0; i < keys.length; i++) { + const currentKey = keys[i]; + if (FORBIDDEN_KEYS.includes(currentKey)) { + throw rpcErrors.invalidParams( + 'Invalid params: Key contains forbidden characters.', + ); + } + + if (i === keys.length - 1) { + currentObject[currentKey] = value; + return requiredObject; + } + + if ( + !hasProperty(currentObject, currentKey) || + currentObject[currentKey] === null + ) { + currentObject[currentKey] = {}; + } else if (!isObject(currentObject[currentKey])) { + throw rpcErrors.invalidParams( + 'Invalid params: Cannot overwrite non-object value.', + ); + } + + currentObject = currentObject[currentKey] as Record; + } + + // This should never be reached. + /* istanbul ignore next */ + throw new Error('Unexpected error while setting the state.'); +} diff --git a/packages/snaps-rpc-methods/src/utils.test.ts b/packages/snaps-rpc-methods/src/utils.test.ts index 6241b90e4e..211be807ef 100644 --- a/packages/snaps-rpc-methods/src/utils.test.ts +++ b/packages/snaps-rpc-methods/src/utils.test.ts @@ -1,8 +1,15 @@ import { SIP_6_MAGIC_VALUE } from '@metamask/snaps-utils'; import { TEST_SECRET_RECOVERY_PHRASE_BYTES } from '@metamask/snaps-utils/test-utils'; +import { create, is } from '@metamask/superstruct'; import { ENTROPY_VECTORS } from './__fixtures__'; -import { deriveEntropy, getNode, getPathPrefix } from './utils'; +import { + deriveEntropy, + getNode, + getPathPrefix, + isValidStateKey, + StateKeyStruct, +} from './utils'; describe('deriveEntropy', () => { it.each(ENTROPY_VECTORS)( @@ -14,6 +21,7 @@ describe('deriveEntropy', () => { salt, mnemonicPhrase: TEST_SECRET_RECOVERY_PHRASE_BYTES, magic: SIP_6_MAGIC_VALUE, + cryptographicFunctions: {}, }), ).toStrictEqual(entropy); }, @@ -47,6 +55,7 @@ describe('getNode', () => { curve: 'secp256k1', path: ['m', "44'", "1'"], secretRecoveryPhrase: TEST_SECRET_RECOVERY_PHRASE_BYTES, + cryptographicFunctions: {}, }); expect(node).toMatchInlineSnapshot(` @@ -69,6 +78,7 @@ describe('getNode', () => { curve: 'ed25519', path: ['m', "44'", "1'"], secretRecoveryPhrase: TEST_SECRET_RECOVERY_PHRASE_BYTES, + cryptographicFunctions: {}, }); expect(node).toMatchInlineSnapshot(` @@ -86,3 +96,34 @@ describe('getNode', () => { `); }); }); + +describe('isValidStateKey', () => { + it.each(['foo', 'foo.bar', 'foo.bar.baz'])( + 'returns `true` for "%s"', + (key) => { + expect(isValidStateKey(key)).toBe(true); + }, + ); + + it.each(['', '.', '..', 'foo.', 'foo..bar', 'foo.bar.', 'foo.bar..baz'])( + 'returns `false` for "%s"', + (key) => { + expect(isValidStateKey(key)).toBe(false); + }, + ); +}); + +describe('StateKeyStruct', () => { + it.each(['foo', 'foo.bar', 'foo.bar.baz'])('accepts "%s"', (key) => { + expect(is(key, StateKeyStruct)).toBe(true); + }); + + it.each(['', '.', '..', 'foo.', 'foo..bar', 'foo.bar.', 'foo.bar..baz'])( + 'does not accept "%s"', + (key) => { + expect(() => create(key, StateKeyStruct)).toThrow( + 'Invalid state key. Each part of the key must be non-empty.', + ); + }, + ); +}); diff --git a/packages/snaps-rpc-methods/src/utils.ts b/packages/snaps-rpc-methods/src/utils.ts index 500038ff5b..ae76a7f16a 100644 --- a/packages/snaps-rpc-methods/src/utils.ts +++ b/packages/snaps-rpc-methods/src/utils.ts @@ -7,6 +7,7 @@ import type { } from '@metamask/key-tree'; import { SLIP10Node } from '@metamask/key-tree'; import type { MagicValue } from '@metamask/snaps-utils'; +import { refine, string } from '@metamask/superstruct'; import type { Hex } from '@metamask/utils'; import { assertExhaustive, @@ -20,6 +21,8 @@ import { keccak_256 as keccak256 } from '@noble/hashes/sha3'; const HARDENED_VALUE = 0x80000000; +export const FORBIDDEN_KEYS = ['constructor', '__proto__', 'prototype']; + /** * Maps an interface with method hooks to an object, using the keys of the * interface, and `true` as value. This ensures that the `methodHooks` object @@ -238,3 +241,25 @@ export async function getNode({ cryptographicFunctions, ); } + +/** + * Validate the key of a state object. + * + * @param key - The key to validate. + * @returns `true` if the key is valid, `false` otherwise. + */ +export function isValidStateKey(key: string | undefined) { + if (key === undefined) { + return true; + } + + return key.split('.').every((part) => part.length > 0); +} + +export const StateKeyStruct = refine(string(), 'state key', (value) => { + if (!isValidStateKey(value)) { + return 'Invalid state key. Each part of the key must be non-empty.'; + } + + return true; +}); diff --git a/packages/snaps-sdk/src/types/methods/clear-state.ts b/packages/snaps-sdk/src/types/methods/clear-state.ts new file mode 100644 index 0000000000..0db3edc778 --- /dev/null +++ b/packages/snaps-sdk/src/types/methods/clear-state.ts @@ -0,0 +1,16 @@ +/** + * The request parameters for the `snap_clearState` method. + * + * @property encrypted - Whether to use the separate encrypted state, or the + * unencrypted state. Defaults to the encrypted state. Encrypted state can only + * be used if the extension is unlocked, while unencrypted state can be used + * whether the extension is locked or unlocked. + */ +export type ClearStateParams = { + encrypted?: boolean; +}; + +/** + * The result returned by the `snap_clearState` method. + */ +export type ClearStateResult = null; diff --git a/packages/snaps-sdk/src/types/methods/get-state.ts b/packages/snaps-sdk/src/types/methods/get-state.ts new file mode 100644 index 0000000000..e3931bca6a --- /dev/null +++ b/packages/snaps-sdk/src/types/methods/get-state.ts @@ -0,0 +1,22 @@ +import type { Json } from '@metamask/utils'; + +/** + * The request parameters for the `snap_getState` method. + * + * @property key - The key of the state to retrieve. If not provided, the entire + * state is retrieved. This may contain Lodash-style path syntax, e.g., + * `a.b.c`, with the exception of array syntax. + * @property encrypted - Whether to use the separate encrypted state, or the + * unencrypted state. Defaults to the encrypted state. Encrypted state can only + * be used if the client is unlocked, while unencrypted state can be used + * whether the client is locked or unlocked. + */ +export type GetStateParams = { + key?: string; + encrypted?: boolean; +}; + +/** + * The result returned by the `snap_getState` method. + */ +export type GetStateResult = Json; diff --git a/packages/snaps-sdk/src/types/methods/index.ts b/packages/snaps-sdk/src/types/methods/index.ts index 02d604df85..70bd559285 100644 --- a/packages/snaps-sdk/src/types/methods/index.ts +++ b/packages/snaps-sdk/src/types/methods/index.ts @@ -1,9 +1,11 @@ +export * from './clear-state'; export * from './create-interface'; export * from './dialog'; export * from './get-bip32-entropy'; export * from './get-bip32-public-key'; export * from './get-bip44-entropy'; export * from './get-client-status'; +export * from './get-currency-rate'; export * from './get-entropy'; export * from './get-file'; export * from './get-interface-context'; @@ -11,14 +13,15 @@ export * from './get-interface-state'; export * from './get-locale'; export * from './get-preferences'; export * from './get-snaps'; +export * from './get-state'; export * from './invoke-keyring'; export * from './invoke-snap'; export * from './manage-accounts'; export * from './manage-state'; export * from './methods'; export * from './notify'; +export * from './provider-request'; export * from './request-snaps'; export * from './update-interface'; export * from './resolve-interface'; -export * from './get-currency-rate'; -export * from './provider-request'; +export * from './set-state'; diff --git a/packages/snaps-sdk/src/types/methods/methods.ts b/packages/snaps-sdk/src/types/methods/methods.ts index cf671a6254..21386d0fd8 100644 --- a/packages/snaps-sdk/src/types/methods/methods.ts +++ b/packages/snaps-sdk/src/types/methods/methods.ts @@ -1,4 +1,5 @@ import type { Method } from '../../internals'; +import type { ClearStateParams, ClearStateResult } from './clear-state'; import type { CreateInterfaceParams, CreateInterfaceResult, @@ -40,6 +41,7 @@ import type { GetPreferencesResult, } from './get-preferences'; import type { GetSnapsParams, GetSnapsResult } from './get-snaps'; +import type { GetStateParams, GetStateResult } from './get-state'; import type { InvokeKeyringParams, InvokeKeyringResult, @@ -56,6 +58,7 @@ import type { ResolveInterfaceParams, ResolveInterfaceResult, } from './resolve-interface'; +import type { SetStateParams, SetStateResult } from './set-state'; import type { UpdateInterfaceParams, UpdateInterfaceResult, @@ -67,6 +70,7 @@ import type { */ export type SnapMethods = { /* eslint-disable @typescript-eslint/naming-convention */ + snap_clearState: [ClearStateParams, ClearStateResult]; snap_dialog: [DialogParams, DialogResult]; snap_getBip32Entropy: [GetBip32EntropyParams, GetBip32EntropyResult]; snap_getBip32PublicKey: [GetBip32PublicKeyParams, GetBip32PublicKeyResult]; @@ -77,6 +81,7 @@ export type SnapMethods = { snap_getFile: [GetFileParams, GetFileResult]; snap_getLocale: [GetLocaleParams, GetLocaleResult]; snap_getPreferences: [GetPreferencesParams, GetPreferencesResult]; + snap_getState: [GetStateParams, GetStateResult]; snap_manageAccounts: [ManageAccountsParams, ManageAccountsResult]; snap_manageState: [ManageStateParams, ManageStateResult]; snap_notify: [NotifyParams, NotifyResult]; @@ -88,6 +93,7 @@ export type SnapMethods = { GetInterfaceContextResult, ]; snap_resolveInterface: [ResolveInterfaceParams, ResolveInterfaceResult]; + snap_setState: [SetStateParams, SetStateResult]; wallet_getSnaps: [GetSnapsParams, GetSnapsResult]; wallet_invokeKeyring: [InvokeKeyringParams, InvokeKeyringResult]; wallet_invokeSnap: [InvokeSnapParams, InvokeSnapResult]; diff --git a/packages/snaps-sdk/src/types/methods/set-state.ts b/packages/snaps-sdk/src/types/methods/set-state.ts new file mode 100644 index 0000000000..d0975cc78b --- /dev/null +++ b/packages/snaps-sdk/src/types/methods/set-state.ts @@ -0,0 +1,24 @@ +import type { Json } from '@metamask/utils'; + +/** + * The request parameters for the `snap_setState` method. + * + * @property key - The key of the state to update. If not provided, the entire + * state is updated. This may contain Lodash-style path syntax, e.g., + * `a.b.c`, with the exception of array syntax. + * @property value - The value to set the state to. + * @property encrypted - Whether to use the separate encrypted state, or the + * unencrypted state. Defaults to the encrypted state. Encrypted state can only + * be used if the client is unlocked, while unencrypted state can be used + * whether the client is locked or unlocked. + */ +export type SetStateParams = { + key?: string; + value: Json; + encrypted?: boolean; +}; + +/** + * The result returned by the `snap_setState` method. + */ +export type SetStateResult = null; diff --git a/packages/test-snaps/src/api.ts b/packages/test-snaps/src/api.ts index 913a98de23..4c4f458d6e 100644 --- a/packages/test-snaps/src/api.ts +++ b/packages/test-snaps/src/api.ts @@ -1,5 +1,7 @@ -import type { MetaMaskInpageProvider } from '@metamask/providers'; -import type { RequestArguments } from '@metamask/providers/dist/BaseProvider'; +import type { + MetaMaskInpageProvider, + RequestArguments, +} from '@metamask/providers'; import { logError } from '@metamask/snaps-utils'; import type { JsonRpcError, JsonRpcParams } from '@metamask/utils'; import type { BaseQueryFn } from '@reduxjs/toolkit/query/react'; @@ -118,6 +120,7 @@ export const baseApi = createApi({ }), invalidatesTags: [Tag.InstalledSnaps], }), + installSnaps: build.mutation({ query: (params) => ({ method: 'wallet_requestSnaps', diff --git a/packages/test-snaps/src/features/snaps/index.ts b/packages/test-snaps/src/features/snaps/index.ts index c80978a40d..7eadf6599e 100644 --- a/packages/test-snaps/src/features/snaps/index.ts +++ b/packages/test-snaps/src/features/snaps/index.ts @@ -16,7 +16,7 @@ export * from './images'; export * from './json-rpc'; export * from './jsx'; export * from './lifecycle-hooks'; -export * from './manage-state'; +export * from './legacy-state'; export * from './multi-install'; export * from './name-lookup'; export * from './network-access'; @@ -27,3 +27,4 @@ export * from './signature-insights'; export * from './updates'; export * from './wasm'; export * from './send-flow'; +export * from './state'; diff --git a/packages/test-snaps/src/features/snaps/manage-state/ManageState.tsx b/packages/test-snaps/src/features/snaps/legacy-state/LegacyState.tsx similarity index 69% rename from packages/test-snaps/src/features/snaps/manage-state/ManageState.tsx rename to packages/test-snaps/src/features/snaps/legacy-state/LegacyState.tsx index 386e56a704..665369b800 100644 --- a/packages/test-snaps/src/features/snaps/manage-state/ManageState.tsx +++ b/packages/test-snaps/src/features/snaps/legacy-state/LegacyState.tsx @@ -1,26 +1,27 @@ import type { FunctionComponent } from 'react'; import { Result, Snap } from '../../../components'; -import { ClearData, SendData } from './components'; import { MANAGE_STATE_SNAP_ID, MANAGE_STATE_PORT, MANAGE_STATE_VERSION, -} from './constants'; -import { useSnapState } from './hooks'; +} from '../state/constants'; +import { useSnapState } from '../state/hooks'; +import { ClearData, SendData } from './components'; -export const ManageState: FunctionComponent = () => { - const encryptedState = useSnapState(true); - const unencryptedState = useSnapState(false); +export const LegacyState: FunctionComponent = () => { + const encryptedState = useSnapState('legacy_getState', true); + const unencryptedState = useSnapState('legacy_getState', false); return ( +

Encrypted state

{JSON.stringify(encryptedState, null, 2)} @@ -30,6 +31,7 @@ export const ManageState: FunctionComponent = () => { +

Unencrypted state

{JSON.stringify(unencryptedState, null, 2)} diff --git a/packages/test-snaps/src/features/snaps/manage-state/components/ClearData.tsx b/packages/test-snaps/src/features/snaps/legacy-state/components/ClearData.tsx similarity index 91% rename from packages/test-snaps/src/features/snaps/manage-state/components/ClearData.tsx rename to packages/test-snaps/src/features/snaps/legacy-state/components/ClearData.tsx index d250e03022..e1cf0d88db 100644 --- a/packages/test-snaps/src/features/snaps/manage-state/components/ClearData.tsx +++ b/packages/test-snaps/src/features/snaps/legacy-state/components/ClearData.tsx @@ -5,7 +5,7 @@ import { Button } from 'react-bootstrap'; import { Tag, useInvokeMutation } from '../../../../api'; import { Result } from '../../../../components'; import { getSnapId } from '../../../../utils'; -import { MANAGE_STATE_PORT, MANAGE_STATE_SNAP_ID } from '../constants'; +import { MANAGE_STATE_PORT, MANAGE_STATE_SNAP_ID } from '../../state/constants'; export const ClearData: FunctionComponent<{ encrypted: boolean }> = ({ encrypted, @@ -15,7 +15,7 @@ export const ClearData: FunctionComponent<{ encrypted: boolean }> = ({ const handleClick = () => { invokeSnap({ snapId: getSnapId(MANAGE_STATE_SNAP_ID, MANAGE_STATE_PORT), - method: 'clearState', + method: 'legacy_clearState', params: { encrypted }, tags: [encrypted ? Tag.TestState : Tag.UnencryptedTestState], }).catch(logError); diff --git a/packages/test-snaps/src/features/snaps/manage-state/components/SendData.tsx b/packages/test-snaps/src/features/snaps/legacy-state/components/SendData.tsx similarity index 85% rename from packages/test-snaps/src/features/snaps/manage-state/components/SendData.tsx rename to packages/test-snaps/src/features/snaps/legacy-state/components/SendData.tsx index 2903b4ce7f..70eea82e0b 100644 --- a/packages/test-snaps/src/features/snaps/manage-state/components/SendData.tsx +++ b/packages/test-snaps/src/features/snaps/legacy-state/components/SendData.tsx @@ -6,15 +6,18 @@ import { Button, Form } from 'react-bootstrap'; import { Tag, useInvokeMutation } from '../../../../api'; import { Result } from '../../../../components'; import { getSnapId } from '../../../../utils'; -import { MANAGE_STATE_PORT, MANAGE_STATE_SNAP_ID } from '../constants'; -import { useSnapState } from '../hooks'; +import { MANAGE_STATE_PORT, MANAGE_STATE_SNAP_ID } from '../../state/constants'; +import { useSnapState } from '../../state/hooks'; export const SendData: FunctionComponent<{ encrypted: boolean }> = ({ encrypted, }) => { const [value, setValue] = useState(''); const [invokeSnap, { isLoading, data, error }] = useInvokeMutation(); - const snapState = useSnapState(encrypted); + const snapState = useSnapState<{ items: string[] }>( + 'legacy_getState', + encrypted, + ); const handleChange = (event: ChangeEvent) => { setValue(event.target.value); @@ -22,11 +25,13 @@ export const SendData: FunctionComponent<{ encrypted: boolean }> = ({ const handleSubmit = (event: FormEvent) => { event.preventDefault(); + const items = snapState?.items ?? []; + invokeSnap({ snapId: getSnapId(MANAGE_STATE_SNAP_ID, MANAGE_STATE_PORT), - method: 'setState', + method: 'legacy_setState', params: { - items: [...snapState.items, value], + items: [...items, value], encrypted, }, tags: [encrypted ? Tag.TestState : Tag.UnencryptedTestState], diff --git a/packages/test-snaps/src/features/snaps/manage-state/components/index.ts b/packages/test-snaps/src/features/snaps/legacy-state/components/index.ts similarity index 100% rename from packages/test-snaps/src/features/snaps/manage-state/components/index.ts rename to packages/test-snaps/src/features/snaps/legacy-state/components/index.ts diff --git a/packages/test-snaps/src/features/snaps/legacy-state/index.ts b/packages/test-snaps/src/features/snaps/legacy-state/index.ts new file mode 100644 index 0000000000..4e42145e67 --- /dev/null +++ b/packages/test-snaps/src/features/snaps/legacy-state/index.ts @@ -0,0 +1 @@ +export * from './LegacyState'; diff --git a/packages/test-snaps/src/features/snaps/manage-state/index.ts b/packages/test-snaps/src/features/snaps/manage-state/index.ts deleted file mode 100644 index 65d95d15b4..0000000000 --- a/packages/test-snaps/src/features/snaps/manage-state/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './ManageState'; diff --git a/packages/test-snaps/src/features/snaps/state/State.tsx b/packages/test-snaps/src/features/snaps/state/State.tsx new file mode 100644 index 0000000000..d0e7dcc5a5 --- /dev/null +++ b/packages/test-snaps/src/features/snaps/state/State.tsx @@ -0,0 +1,56 @@ +import type { FunctionComponent } from 'react'; + +import { Result, Snap } from '../../../components'; +import { ClearState, GetState, SetState } from './components'; +import { + MANAGE_STATE_SNAP_ID, + MANAGE_STATE_PORT, + MANAGE_STATE_VERSION, +} from './constants'; +import { useSnapState } from './hooks'; + +export const State: FunctionComponent = () => { + const encryptedState = useSnapState('getState', true); + const unencryptedState = useSnapState('getState', false); + + return ( + +

Encrypted state

+ +
+          {JSON.stringify(encryptedState, null, 2)}
+        
+
+ + + + + +

Unencrypted state

+ +
+          {JSON.stringify(unencryptedState, null, 2)}
+        
+
+ + + +
+ ); +}; diff --git a/packages/test-snaps/src/features/snaps/state/components/ClearState.tsx b/packages/test-snaps/src/features/snaps/state/components/ClearState.tsx new file mode 100644 index 0000000000..4923db7fae --- /dev/null +++ b/packages/test-snaps/src/features/snaps/state/components/ClearState.tsx @@ -0,0 +1,44 @@ +import { logError } from '@metamask/snaps-utils'; +import type { FunctionComponent } from 'react'; +import { Button } from 'react-bootstrap'; + +import { Tag, useInvokeMutation } from '../../../../api'; +import { Result } from '../../../../components'; +import { getSnapId } from '../../../../utils'; +import { MANAGE_STATE_PORT, MANAGE_STATE_SNAP_ID } from '../constants'; + +export const ClearState: FunctionComponent<{ encrypted: boolean }> = ({ + encrypted, +}) => { + const [invokeSnap, { isLoading, data, error }] = useInvokeMutation(); + + const handleClick = () => { + invokeSnap({ + snapId: getSnapId(MANAGE_STATE_SNAP_ID, MANAGE_STATE_PORT), + method: 'clearState', + params: { encrypted }, + tags: [encrypted ? Tag.TestState : Tag.UnencryptedTestState], + }).catch(logError); + }; + + return ( + <> + + + + {JSON.stringify(data, null, 2)} + {JSON.stringify(error, null, 2)} + + + + ); +}; diff --git a/packages/test-snaps/src/features/snaps/state/components/GetState.tsx b/packages/test-snaps/src/features/snaps/state/components/GetState.tsx new file mode 100644 index 0000000000..7d4b925b35 --- /dev/null +++ b/packages/test-snaps/src/features/snaps/state/components/GetState.tsx @@ -0,0 +1,66 @@ +import { logError } from '@metamask/snaps-utils'; +import type { ChangeEvent, FormEvent, FunctionComponent } from 'react'; +import { useState } from 'react'; +import { Button, Form } from 'react-bootstrap'; + +import { Tag, useInvokeMutation } from '../../../../api'; +import { Result } from '../../../../components'; +import { getSnapId } from '../../../../utils'; +import { MANAGE_STATE_PORT, MANAGE_STATE_SNAP_ID } from '../constants'; + +export const GetState: FunctionComponent<{ encrypted: boolean }> = ({ + encrypted, +}) => { + const [key, setKey] = useState(''); + const [invokeSnap, { isLoading, data, error }] = useInvokeMutation(); + + const handleChange = (event: ChangeEvent) => { + setKey(event.target.value); + }; + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + invokeSnap({ + snapId: getSnapId(MANAGE_STATE_SNAP_ID, MANAGE_STATE_PORT), + method: 'getState', + params: { + key, + encrypted, + }, + tags: [encrypted ? Tag.TestState : Tag.UnencryptedTestState], + }).catch(logError); + }; + + return ( + <> +
+ + Key + + + + +
+ + + + {JSON.stringify(data, null, 2)} + {JSON.stringify(error, null, 2)} + + + + ); +}; diff --git a/packages/test-snaps/src/features/snaps/state/components/SetState.tsx b/packages/test-snaps/src/features/snaps/state/components/SetState.tsx new file mode 100644 index 0000000000..03bdc9744a --- /dev/null +++ b/packages/test-snaps/src/features/snaps/state/components/SetState.tsx @@ -0,0 +1,90 @@ +import { logError } from '@metamask/snaps-utils'; +import type { ChangeEvent, FormEvent, FunctionComponent } from 'react'; +import { useState } from 'react'; +import { Button, Form } from 'react-bootstrap'; + +import { Tag, useInvokeMutation } from '../../../../api'; +import { Result } from '../../../../components'; +import { getSnapId } from '../../../../utils'; +import { MANAGE_STATE_PORT, MANAGE_STATE_SNAP_ID } from '../constants'; + +export const SetState: FunctionComponent<{ encrypted: boolean }> = ({ + encrypted, +}) => { + const [key, setKey] = useState(''); + const [value, setValue] = useState(''); + const [invokeSnap, { isLoading, data, error }] = useInvokeMutation(); + + const handleChangeKey = (event: ChangeEvent) => { + setKey(event.target.value); + }; + + const handleChangeValue = (event: ChangeEvent) => { + setValue(event.target.value); + }; + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + invokeSnap({ + snapId: getSnapId(MANAGE_STATE_SNAP_ID, MANAGE_STATE_PORT), + method: 'setState', + params: { + key, + value: JSON.parse(value), + encrypted, + }, + tags: [encrypted ? Tag.TestState : Tag.UnencryptedTestState], + }).catch(logError); + }; + + return ( + <> +
+ + Key + + + + + Value + + + + +
+ + + + {JSON.stringify(data, null, 2)} + {JSON.stringify(error, null, 2)} + + + + ); +}; diff --git a/packages/test-snaps/src/features/snaps/state/components/index.ts b/packages/test-snaps/src/features/snaps/state/components/index.ts new file mode 100644 index 0000000000..3f13fdefea --- /dev/null +++ b/packages/test-snaps/src/features/snaps/state/components/index.ts @@ -0,0 +1,3 @@ +export * from './ClearState'; +export * from './GetState'; +export * from './SetState'; diff --git a/packages/test-snaps/src/features/snaps/manage-state/constants.ts b/packages/test-snaps/src/features/snaps/state/constants.ts similarity index 100% rename from packages/test-snaps/src/features/snaps/manage-state/constants.ts rename to packages/test-snaps/src/features/snaps/state/constants.ts diff --git a/packages/test-snaps/src/features/snaps/manage-state/hooks/index.ts b/packages/test-snaps/src/features/snaps/state/hooks/index.ts similarity index 100% rename from packages/test-snaps/src/features/snaps/manage-state/hooks/index.ts rename to packages/test-snaps/src/features/snaps/state/hooks/index.ts diff --git a/packages/test-snaps/src/features/snaps/manage-state/hooks/useSnapState.ts b/packages/test-snaps/src/features/snaps/state/hooks/useSnapState.ts similarity index 74% rename from packages/test-snaps/src/features/snaps/manage-state/hooks/useSnapState.ts rename to packages/test-snaps/src/features/snaps/state/hooks/useSnapState.ts index af53efa433..dffe19c0c0 100644 --- a/packages/test-snaps/src/features/snaps/manage-state/hooks/useSnapState.ts +++ b/packages/test-snaps/src/features/snaps/state/hooks/useSnapState.ts @@ -1,25 +1,26 @@ +import type { Json } from '@metamask/utils'; + import { Tag, useInvokeQuery } from '../../../../api'; import { getSnapId, useInstalled } from '../../../../utils'; import { MANAGE_STATE_PORT, MANAGE_STATE_SNAP_ID } from '../constants'; -export type State = { - items: string[]; -}; - /** * Hook to retrieve the state of the snap. * + * @param method - The method to call on the Snap. * @param encrypted - A flag to indicate whether to use encrypted storage or not. * @returns The state of the snap. */ -export function useSnapState(encrypted: boolean) { +export function useSnapState< + State extends Record = Record, +>(method: string, encrypted: boolean): State { const snapId = getSnapId(MANAGE_STATE_SNAP_ID, MANAGE_STATE_PORT); const isInstalled = useInstalled(snapId); const { data: state } = useInvokeQuery<{ data: State }>( { snapId, - method: 'getState', + method, params: { encrypted }, tags: [encrypted ? Tag.TestState : Tag.UnencryptedTestState], }, diff --git a/packages/test-snaps/src/features/snaps/state/index.ts b/packages/test-snaps/src/features/snaps/state/index.ts new file mode 100644 index 0000000000..e11bf86ccf --- /dev/null +++ b/packages/test-snaps/src/features/snaps/state/index.ts @@ -0,0 +1 @@ +export * from './State'; diff --git a/yarn.lock b/yarn.lock index 5e73b80771..0b7a429e29 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5144,6 +5144,7 @@ __metadata: "@metamask/snaps-cli": "workspace:^" "@metamask/snaps-jest": "workspace:^" "@metamask/snaps-sdk": "workspace:^" + "@metamask/utils": "npm:^10.0.0" "@swc/core": "npm:1.3.78" "@swc/jest": "npm:^0.2.26" "@typescript-eslint/eslint-plugin": "npm:^5.42.1"