From 1f3f7a1db588ea382eac165a6c2c520ef8e87460 Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Tue, 25 Feb 2025 13:22:11 +0100 Subject: [PATCH 1/5] Implement `snap_listEntropySources` --- packages/snaps-rpc-methods/jest.config.js | 6 +- .../src/permitted/handlers.ts | 2 + .../snaps-rpc-methods/src/permitted/index.ts | 2 + .../src/permitted/listEntropySources.test.ts | 140 ++++++++++++++++++ .../src/permitted/listEntropySources.ts | 89 +++++++++++ packages/snaps-sdk/src/types/methods/index.ts | 1 + .../src/types/methods/list-entropy-sources.ts | 38 +++++ .../snaps-sdk/src/types/methods/methods.ts | 5 + 8 files changed, 280 insertions(+), 3 deletions(-) create mode 100644 packages/snaps-rpc-methods/src/permitted/listEntropySources.test.ts create mode 100644 packages/snaps-rpc-methods/src/permitted/listEntropySources.ts create mode 100644 packages/snaps-sdk/src/types/methods/list-entropy-sources.ts diff --git a/packages/snaps-rpc-methods/jest.config.js b/packages/snaps-rpc-methods/jest.config.js index 95c324bc1f..55e90b2dcc 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: 95.09, + branches: 95.11, functions: 98.61, - lines: 98.8, - statements: 98.47, + lines: 98.81, + statements: 98.49, }, }, }); diff --git a/packages/snaps-rpc-methods/src/permitted/handlers.ts b/packages/snaps-rpc-methods/src/permitted/handlers.ts index cf8239f1a1..e14c8fc9fa 100644 --- a/packages/snaps-rpc-methods/src/permitted/handlers.ts +++ b/packages/snaps-rpc-methods/src/permitted/handlers.ts @@ -13,6 +13,7 @@ import { getSnapsHandler } from './getSnaps'; import { getStateHandler } from './getState'; import { invokeKeyringHandler } from './invokeKeyring'; import { invokeSnapSugarHandler } from './invokeSnapSugar'; +import { listEntropySourcesHandler } from './listEntropySources'; import { requestSnapsHandler } from './requestSnaps'; import { resolveInterfaceHandler } from './resolveInterface'; import { scheduleBackgroundEventHandler } from './scheduleBackgroundEvent'; @@ -34,6 +35,7 @@ export const methodHandlers = { snap_updateInterface: updateInterfaceHandler, snap_getInterfaceState: getInterfaceStateHandler, snap_getInterfaceContext: getInterfaceContextHandler, + snap_listEntropySources: listEntropySourcesHandler, snap_resolveInterface: resolveInterfaceHandler, snap_getCurrencyRate: getCurrencyRateHandler, snap_experimentalProviderRequest: providerRequestHandler, diff --git a/packages/snaps-rpc-methods/src/permitted/index.ts b/packages/snaps-rpc-methods/src/permitted/index.ts index 8c9e2a71f2..50324bb346 100644 --- a/packages/snaps-rpc-methods/src/permitted/index.ts +++ b/packages/snaps-rpc-methods/src/permitted/index.ts @@ -9,6 +9,7 @@ import type { GetCurrencyRateMethodHooks } from './getCurrencyRate'; import type { GetInterfaceStateMethodHooks } from './getInterfaceState'; import type { GetSnapsHooks } from './getSnaps'; import type { GetStateHooks } from './getState'; +import type { ListEntropySourcesHooks } from './listEntropySources'; import type { RequestSnapsHooks } from './requestSnaps'; import type { ResolveInterfaceMethodHooks } from './resolveInterface'; import type { ScheduleBackgroundEventMethodHooks } from './scheduleBackgroundEvent'; @@ -20,6 +21,7 @@ export type PermittedRpcMethodHooks = ClearStateHooks & GetClientStatusHooks & GetSnapsHooks & GetStateHooks & + ListEntropySourcesHooks & RequestSnapsHooks & CreateInterfaceMethodHooks & UpdateInterfaceMethodHooks & diff --git a/packages/snaps-rpc-methods/src/permitted/listEntropySources.test.ts b/packages/snaps-rpc-methods/src/permitted/listEntropySources.test.ts new file mode 100644 index 0000000000..f0fde80366 --- /dev/null +++ b/packages/snaps-rpc-methods/src/permitted/listEntropySources.test.ts @@ -0,0 +1,140 @@ +import { JsonRpcEngine } from '@metamask/json-rpc-engine'; +import type { + ListEntropySourcesParams, + ListEntropySourcesResult, +} from '@metamask/snaps-sdk'; +import type { JsonRpcRequest, PendingJsonRpcResponse } from '@metamask/utils'; + +import { listEntropySourcesHandler } from './listEntropySources'; + +describe('snap_listEntropySources', () => { + describe('listEntropySourcesHandler', () => { + it('has the expected shape', () => { + expect(listEntropySourcesHandler).toMatchObject({ + methodNames: ['snap_listEntropySources'], + implementation: expect.any(Function), + hookNames: { + hasPermission: true, + getEntropySources: true, + }, + }); + }); + }); + + describe('implementation', () => { + it('returns the result from the `getEntropySources` hook', async () => { + const { implementation } = listEntropySourcesHandler; + + const getEntropySources = jest.fn().mockReturnValue([ + { + name: 'Secret recovery phrase 1', + id: 'foo', + type: 'mnemonic', + primary: false, + }, + { + name: 'Secret recovery phrase 2', + id: 'bar', + type: 'mnemonic', + primary: false, + }, + { + name: 'Primary secret recovery phrase', + id: 'baz', + type: 'mnemonic', + primary: true, + }, + ]); + + const hooks = { + hasPermission: jest.fn().mockReturnValue(true), + getEntropySources, + }; + + 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_listEntropySources', + }); + + expect(response).toStrictEqual({ + jsonrpc: '2.0', + id: 1, + result: [ + { + name: 'Secret recovery phrase 1', + id: 'foo', + type: 'mnemonic', + primary: false, + }, + { + name: 'Secret recovery phrase 2', + id: 'bar', + type: 'mnemonic', + primary: false, + }, + { + name: 'Primary secret recovery phrase', + id: 'baz', + type: 'mnemonic', + primary: true, + }, + ], + }); + }); + + it('returns an unauthorized error if the requesting origin does not have the required permission', async () => { + const { implementation } = listEntropySourcesHandler; + + const hooks = { + hasPermission: jest.fn().mockReturnValue(false), + getEntropySources: jest.fn(), + }; + + 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_listEntropySources', + }); + + expect(response).toStrictEqual({ + jsonrpc: '2.0', + id: 1, + error: { + code: 4100, + message: + 'The requested account and/or method has not been authorized by the user.', + stack: expect.any(String), + }, + }); + }); + }); +}); diff --git a/packages/snaps-rpc-methods/src/permitted/listEntropySources.ts b/packages/snaps-rpc-methods/src/permitted/listEntropySources.ts new file mode 100644 index 0000000000..2ca739d808 --- /dev/null +++ b/packages/snaps-rpc-methods/src/permitted/listEntropySources.ts @@ -0,0 +1,89 @@ +import type { JsonRpcEngineEndCallback } from '@metamask/json-rpc-engine'; +import type { PermittedHandlerExport } from '@metamask/permission-controller'; +import { providerErrors } from '@metamask/rpc-errors'; +import type { + EntropySource, + JsonRpcRequest, + ListEntropySourcesParams, + ListEntropySourcesResult, +} from '@metamask/snaps-sdk'; +import type { PendingJsonRpcResponse } from '@metamask/utils'; + +import { getBip32EntropyBuilder } from '../restricted/getBip32Entropy'; +import { getBip32PublicKeyBuilder } from '../restricted/getBip32PublicKey'; +import { getBip44EntropyBuilder } from '../restricted/getBip44Entropy'; +import { getEntropyBuilder } from '../restricted/getEntropy'; +import type { MethodHooksObject } from '../utils'; + +/** + * A list of permissions that the requesting origin must have at least one of + * in order to call this method. + */ +const REQUIRED_PERMISSIONS = [ + getBip32EntropyBuilder.targetName, + getBip32PublicKeyBuilder.targetName, + getBip44EntropyBuilder.targetName, + getEntropyBuilder.targetName, +]; + +const hookNames: MethodHooksObject = { + hasPermission: true, + getEntropySources: true, +}; + +export type ListEntropySourcesHooks = { + /** + * 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 entropy sources from the client. + * + * @returns The entropy sources. + */ + getEntropySources: () => EntropySource[]; +}; + +export const listEntropySourcesHandler: PermittedHandlerExport< + ListEntropySourcesHooks, + ListEntropySourcesParams, + ListEntropySourcesResult +> = { + methodNames: ['snap_listEntropySources'], + implementation: listEntropySourcesImplementation, + hookNames, +}; + +/** + * The `snap_getInterfaceContext` method implementation. + * + * @param _request - The JSON-RPC request object. Not used by this function. + * @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 - The function to check if the origin has a + * permission. + * @param hooks.getEntropySources - The function to get the entropy sources. + * @returns Noting. + */ +function listEntropySourcesImplementation( + _request: JsonRpcRequest, + response: PendingJsonRpcResponse, + _next: unknown, + end: JsonRpcEngineEndCallback, + { hasPermission, getEntropySources }: ListEntropySourcesHooks, +): void { + const isPermitted = REQUIRED_PERMISSIONS.some(hasPermission); + if (!isPermitted) { + return end(providerErrors.unauthorized()); + } + + response.result = getEntropySources(); + return end(); +} diff --git a/packages/snaps-sdk/src/types/methods/index.ts b/packages/snaps-sdk/src/types/methods/index.ts index 43d5aa2b40..d0c7f22931 100644 --- a/packages/snaps-sdk/src/types/methods/index.ts +++ b/packages/snaps-sdk/src/types/methods/index.ts @@ -16,6 +16,7 @@ export type * from './get-snaps'; export type * from './get-state'; export type * from './invoke-keyring'; export type * from './invoke-snap'; +export type * from './list-entropy-sources'; export type * from './manage-accounts'; export * from './manage-state'; export type * from './methods'; diff --git a/packages/snaps-sdk/src/types/methods/list-entropy-sources.ts b/packages/snaps-sdk/src/types/methods/list-entropy-sources.ts new file mode 100644 index 0000000000..2328165b0e --- /dev/null +++ b/packages/snaps-sdk/src/types/methods/list-entropy-sources.ts @@ -0,0 +1,38 @@ +/** + * An entropy source that can be used to retrieve entropy using the + * `snap_get*Entropy` methods. + */ +export type EntropySource = { + /** + * The name of the entropy source. + */ + name: string; + + /** + * The ID of the entropy source + */ + id: string; + + /** + * The type of the entropy source. Currently, only `mnemonic` is supported. + */ + type: 'mnemonic'; + + /** + * Whether the entropy source is the primary source. + */ + primary: boolean; +}; + +/** + * The request parameters for the `snap_listEntropySources` method. + * + * @property snapId - The ID of the snap to invoke. + * @property request - The JSON-RPC request to send to the snap. + */ +export type ListEntropySourcesParams = never; + +/** + * The result returned by the `snap_listEntropySources` method. + */ +export type ListEntropySourcesResult = EntropySource[]; diff --git a/packages/snaps-sdk/src/types/methods/methods.ts b/packages/snaps-sdk/src/types/methods/methods.ts index b1203fc975..4d4d424eab 100644 --- a/packages/snaps-sdk/src/types/methods/methods.ts +++ b/packages/snaps-sdk/src/types/methods/methods.ts @@ -54,6 +54,10 @@ import type { InvokeKeyringResult, } from './invoke-keyring'; import type { InvokeSnapParams, InvokeSnapResult } from './invoke-snap'; +import type { + ListEntropySourcesParams, + ListEntropySourcesResult, +} from './list-entropy-sources'; import type { ManageAccountsParams, ManageAccountsResult, @@ -94,6 +98,7 @@ export type SnapMethods = { snap_getLocale: [GetLocaleParams, GetLocaleResult]; snap_getPreferences: [GetPreferencesParams, GetPreferencesResult]; snap_getState: [GetStateParams, GetStateResult]; + snap_listEntropySources: [ListEntropySourcesParams, ListEntropySourcesResult]; snap_manageAccounts: [ManageAccountsParams, ManageAccountsResult]; snap_manageState: [ManageStateParams, ManageStateResult]; snap_notify: [NotifyParams, NotifyResult]; From 2b9a43cf9906557569e4e0dfac247eef8dbb9f14 Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Tue, 25 Feb 2025 14:07:09 +0100 Subject: [PATCH 2/5] Add source parameter to `snap_get*Entropy` methods --- packages/snaps-rpc-methods/jest.config.js | 2 +- .../caveats/permittedCoinTypes.test.ts | 30 +++++++++++++ .../restricted/caveats/permittedCoinTypes.ts | 11 +++++ .../src/restricted/getBip32Entropy.test.ts | 44 ++++++++++++++++++- .../src/restricted/getBip32Entropy.ts | 11 +++-- .../src/restricted/getBip32PublicKey.test.ts | 34 +++++++++++++- .../src/restricted/getBip32PublicKey.ts | 14 ++++-- .../src/restricted/getBip44Entropy.test.ts | 41 ++++++++++++++++- .../src/restricted/getBip44Entropy.ts | 15 +++++-- .../src/restricted/getEntropy.test.ts | 37 ++++++++++++++++ .../src/restricted/getEntropy.ts | 12 +++-- .../src/types/methods/get-bip32-entropy.ts | 12 +++-- .../src/types/methods/get-bip32-public-key.ts | 15 ++++--- .../src/types/methods/get-bip44-entropy.ts | 11 +++-- .../src/types/methods/get-entropy.ts | 21 ++++++--- 15 files changed, 276 insertions(+), 34 deletions(-) diff --git a/packages/snaps-rpc-methods/jest.config.js b/packages/snaps-rpc-methods/jest.config.js index 55e90b2dcc..96d5e326d5 100644 --- a/packages/snaps-rpc-methods/jest.config.js +++ b/packages/snaps-rpc-methods/jest.config.js @@ -10,7 +10,7 @@ module.exports = deepmerge(baseConfig, { ], coverageThreshold: { global: { - branches: 95.11, + branches: 95.17, functions: 98.61, lines: 98.81, statements: 98.49, diff --git a/packages/snaps-rpc-methods/src/restricted/caveats/permittedCoinTypes.test.ts b/packages/snaps-rpc-methods/src/restricted/caveats/permittedCoinTypes.test.ts index 8a97091316..5d770c3dab 100644 --- a/packages/snaps-rpc-methods/src/restricted/caveats/permittedCoinTypes.test.ts +++ b/packages/snaps-rpc-methods/src/restricted/caveats/permittedCoinTypes.test.ts @@ -67,6 +67,36 @@ describe('validateBIP44Params', () => { 'Invalid "coinType" parameter. Coin type must be a non-negative integer.', ); }); + + it.each([ + {}, + [], + true, + false, + null, + -1, + 1.1, + Infinity, + -Infinity, + NaN, + 0x80000000, + ])('throws an error if the source is invalid', (value) => { + expect(() => { + validateBIP44Params({ coinType: 1, source: value }); + }).toThrow( + 'Invalid "source" parameter. Source must be a string if provided.', + ); + }); + + it.each([ + { coinType: 1 }, + { coinType: 1, source: 'source-id' }, + { coinType: 1, source: undefined }, + ])('does not throw if the parameters are valid', (value) => { + expect(() => { + validateBIP44Params(value); + }).not.toThrow(); + }); }); describe('validateBIP44Caveat', () => { diff --git a/packages/snaps-rpc-methods/src/restricted/caveats/permittedCoinTypes.ts b/packages/snaps-rpc-methods/src/restricted/caveats/permittedCoinTypes.ts index 5937928326..cff93b01a5 100644 --- a/packages/snaps-rpc-methods/src/restricted/caveats/permittedCoinTypes.ts +++ b/packages/snaps-rpc-methods/src/restricted/caveats/permittedCoinTypes.ts @@ -57,6 +57,17 @@ export function validateBIP44Params( }); } + if ( + hasProperty(value, 'source') && + typeof value.source !== 'undefined' && + typeof value.source !== 'string' + ) { + throw rpcErrors.invalidParams({ + message: + 'Invalid "source" parameter. Source must be a string if provided.', + }); + } + if (FORBIDDEN_COIN_TYPES.includes(value.coinType)) { throw rpcErrors.invalidParams({ message: `Coin type ${value.coinType} is forbidden.`, diff --git a/packages/snaps-rpc-methods/src/restricted/getBip32Entropy.test.ts b/packages/snaps-rpc-methods/src/restricted/getBip32Entropy.test.ts index 18f9912323..075ddeb50f 100644 --- a/packages/snaps-rpc-methods/src/restricted/getBip32Entropy.test.ts +++ b/packages/snaps-rpc-methods/src/restricted/getBip32Entropy.test.ts @@ -1,6 +1,9 @@ import { PermissionType, SubjectType } from '@metamask/permission-controller'; import { SnapCaveatType } from '@metamask/snaps-utils'; -import { TEST_SECRET_RECOVERY_PHRASE_BYTES } from '@metamask/snaps-utils/test-utils'; +import { + MOCK_SNAP_ID, + TEST_SECRET_RECOVERY_PHRASE_BYTES, +} from '@metamask/snaps-utils/test-utils'; import { getBip32EntropyBuilder, @@ -193,6 +196,45 @@ describe('getBip32EntropyImplementation', () => { `); }); + it('calls `getMnemonic` with a different entropy source', async () => { + const getMnemonic = jest + .fn() + .mockImplementation(() => TEST_SECRET_RECOVERY_PHRASE_BYTES); + + const getUnlockPromise = jest.fn(); + const getClientCryptography = jest.fn().mockReturnValue({}); + + expect( + await getBip32EntropyImplementation({ + getUnlockPromise, + getMnemonic, + getClientCryptography, + })({ + method: 'snap_getBip32Entropy', + context: { origin: MOCK_SNAP_ID }, + params: { + path: ['m', "44'", "1'"], + curve: 'secp256k1', + source: 'source-id', + }, + }), + ).toMatchInlineSnapshot(` + { + "chainCode": "0x50ccfa58a885b48b5eed09486b3948e8454f34856fb81da5d7b8519d7997abd1", + "curve": "secp256k1", + "depth": 2, + "index": 2147483649, + "masterFingerprint": 1404659567, + "network": "mainnet", + "parentFingerprint": 1829122711, + "privateKey": "0xc73cedb996e7294f032766853a8b7ba11ab4ce9755fc052f2f7b9000044c99af", + "publicKey": "0x048e129862c1de5ca86468add43b001d32fd34b8113de716ecd63fa355b7f1165f0e76f5dc6095100f9fdaa76ddf28aa3f21406ac5fda7c71ffbedb45634fe2ceb", + } + `); + + expect(getMnemonic).toHaveBeenCalledWith('source-id'); + }); + it('uses custom client cryptography functions', async () => { const getUnlockPromise = jest.fn().mockResolvedValue(undefined); const getMnemonic = jest diff --git a/packages/snaps-rpc-methods/src/restricted/getBip32Entropy.ts b/packages/snaps-rpc-methods/src/restricted/getBip32Entropy.ts index 4734d71e9c..c984075863 100644 --- a/packages/snaps-rpc-methods/src/restricted/getBip32Entropy.ts +++ b/packages/snaps-rpc-methods/src/restricted/getBip32Entropy.ts @@ -22,9 +22,14 @@ const targetName = 'snap_getBip32Entropy'; export type GetBip32EntropyMethodHooks = { /** - * @returns The mnemonic of the user's primary keyring. + * Get the mnemonic of the provided source. If no source is provided, the + * mnemonic of the primary keyring will be returned. + * + * @param source - The optional ID of the source to get the mnemonic of. + * @returns The mnemonic of the provided source, or the default source if no + * source is provided. */ - getMnemonic: () => Promise; + getMnemonic: (source?: string | undefined) => Promise; /** * Waits for the extension to be unlocked. @@ -128,7 +133,7 @@ export function getBip32EntropyImplementation({ const node = await getNode({ curve: params.curve, path: params.path, - secretRecoveryPhrase: await getMnemonic(), + secretRecoveryPhrase: await getMnemonic(params.source), cryptographicFunctions: getClientCryptography(), }); diff --git a/packages/snaps-rpc-methods/src/restricted/getBip32PublicKey.test.ts b/packages/snaps-rpc-methods/src/restricted/getBip32PublicKey.test.ts index a626f5f3b4..b77501a220 100644 --- a/packages/snaps-rpc-methods/src/restricted/getBip32PublicKey.test.ts +++ b/packages/snaps-rpc-methods/src/restricted/getBip32PublicKey.test.ts @@ -1,6 +1,9 @@ import { PermissionType, SubjectType } from '@metamask/permission-controller'; import { SnapCaveatType } from '@metamask/snaps-utils'; -import { TEST_SECRET_RECOVERY_PHRASE_BYTES } from '@metamask/snaps-utils/test-utils'; +import { + MOCK_SNAP_ID, + TEST_SECRET_RECOVERY_PHRASE_BYTES, +} from '@metamask/snaps-utils/test-utils'; import { getBip32PublicKeyBuilder, @@ -155,6 +158,35 @@ describe('getBip32PublicKeyImplementation', () => { ); }); + it('calls `getMnemonic` with a different entropy source', async () => { + const getMnemonic = jest + .fn() + .mockImplementation(() => TEST_SECRET_RECOVERY_PHRASE_BYTES); + + const getUnlockPromise = jest.fn(); + const getClientCryptography = jest.fn().mockReturnValue({}); + + expect( + await getBip32PublicKeyImplementation({ + getUnlockPromise, + getMnemonic, + getClientCryptography, + })({ + method: 'snap_getBip32PublicKey', + context: { origin: MOCK_SNAP_ID }, + params: { + path: ['m', "44'", "1'", '1', '2', '3'], + curve: 'secp256k1', + source: 'source-id', + }, + }), + ).toMatchInlineSnapshot( + `"0x042de17487a660993177ce2a85bb73b6cd9ad436184d57bdf5a93f5db430bea914f7c31d378fe68f4723b297a04e49ef55fbf490605c4a3f9ca947a4af4f06526a"`, + ); + + expect(getMnemonic).toHaveBeenCalledWith('source-id'); + }); + it('uses custom client cryptography functions', async () => { const getUnlockPromise = jest.fn().mockResolvedValue(undefined); const getMnemonic = jest diff --git a/packages/snaps-rpc-methods/src/restricted/getBip32PublicKey.ts b/packages/snaps-rpc-methods/src/restricted/getBip32PublicKey.ts index ba9cfc81a0..316e7bd9df 100644 --- a/packages/snaps-rpc-methods/src/restricted/getBip32PublicKey.ts +++ b/packages/snaps-rpc-methods/src/restricted/getBip32PublicKey.ts @@ -17,7 +17,7 @@ import { CurveStruct, SnapCaveatType, } from '@metamask/snaps-utils'; -import { boolean, object, optional } from '@metamask/superstruct'; +import { boolean, object, optional, string } from '@metamask/superstruct'; import type { NonEmptyArray } from '@metamask/utils'; import { assertStruct } from '@metamask/utils'; @@ -28,9 +28,14 @@ const targetName = 'snap_getBip32PublicKey'; export type GetBip32PublicKeyMethodHooks = { /** - * @returns The mnemonic of the user's primary keyring. + * Get the mnemonic of the provided source. If no source is provided, the + * mnemonic of the primary keyring will be returned. + * + * @param source - The optional ID of the source to get the mnemonic of. + * @returns The mnemonic of the provided source, or the default source if no + * source is provided. */ - getMnemonic: () => Promise; + getMnemonic: (source?: string | undefined) => Promise; /** * Waits for the extension to be unlocked. @@ -66,6 +71,7 @@ export const Bip32PublicKeyArgsStruct = bip32entropy( path: Bip32PathStruct, curve: CurveStruct, compressed: optional(boolean()), + source: optional(string()), }), ); @@ -147,7 +153,7 @@ export function getBip32PublicKeyImplementation({ const node = await getNode({ curve: params.curve, path: params.path, - secretRecoveryPhrase: await getMnemonic(), + secretRecoveryPhrase: await getMnemonic(params.source), cryptographicFunctions: getClientCryptography(), }); diff --git a/packages/snaps-rpc-methods/src/restricted/getBip44Entropy.test.ts b/packages/snaps-rpc-methods/src/restricted/getBip44Entropy.test.ts index e689b55cb1..6ba3a52322 100644 --- a/packages/snaps-rpc-methods/src/restricted/getBip44Entropy.test.ts +++ b/packages/snaps-rpc-methods/src/restricted/getBip44Entropy.test.ts @@ -1,6 +1,9 @@ import { SubjectType, PermissionType } from '@metamask/permission-controller'; import { SnapCaveatType } from '@metamask/snaps-utils'; -import { TEST_SECRET_RECOVERY_PHRASE_BYTES } from '@metamask/snaps-utils/test-utils'; +import { + MOCK_SNAP_ID, + TEST_SECRET_RECOVERY_PHRASE_BYTES, +} from '@metamask/snaps-utils/test-utils'; import { getBip44EntropyBuilder, @@ -90,6 +93,42 @@ describe('getBip44EntropyImplementation', () => { `); }); + it('calls `getMnemonic` with a different entropy source', async () => { + const getMnemonic = jest + .fn() + .mockImplementation(() => TEST_SECRET_RECOVERY_PHRASE_BYTES); + + const getUnlockPromise = jest.fn(); + const getClientCryptography = jest.fn().mockReturnValue({}); + + expect( + await getBip44EntropyImplementation({ + getUnlockPromise, + getMnemonic, + getClientCryptography, + })({ + method: 'snap_getBip44Entropy', + context: { origin: MOCK_SNAP_ID }, + params: { coinType: 1, source: 'source-id' }, + }), + ).toMatchInlineSnapshot(` + { + "chainCode": "0x50ccfa58a885b48b5eed09486b3948e8454f34856fb81da5d7b8519d7997abd1", + "coin_type": 1, + "depth": 2, + "index": 2147483649, + "masterFingerprint": 1404659567, + "network": "mainnet", + "parentFingerprint": 1829122711, + "path": "m / bip32:44' / bip32:1'", + "privateKey": "0xc73cedb996e7294f032766853a8b7ba11ab4ce9755fc052f2f7b9000044c99af", + "publicKey": "0x048e129862c1de5ca86468add43b001d32fd34b8113de716ecd63fa355b7f1165f0e76f5dc6095100f9fdaa76ddf28aa3f21406ac5fda7c71ffbedb45634fe2ceb", + } + `); + + expect(getMnemonic).toHaveBeenCalledWith('source-id'); + }); + it('uses custom client cryptography functions', async () => { const getUnlockPromise = jest.fn().mockResolvedValue(undefined); const getMnemonic = jest diff --git a/packages/snaps-rpc-methods/src/restricted/getBip44Entropy.ts b/packages/snaps-rpc-methods/src/restricted/getBip44Entropy.ts index 8fff798b8d..833349a78b 100644 --- a/packages/snaps-rpc-methods/src/restricted/getBip44Entropy.ts +++ b/packages/snaps-rpc-methods/src/restricted/getBip44Entropy.ts @@ -21,9 +21,14 @@ const targetName = 'snap_getBip44Entropy'; export type GetBip44EntropyMethodHooks = { /** - * @returns The mnemonic of the user's primary keyring. + * Get the mnemonic of the provided source. If no source is provided, the + * mnemonic of the primary keyring will be returned. + * + * @param source - The optional ID of the source to get the mnemonic of. + * @returns The mnemonic of the provided source, or the default source if no + * source is provided. */ - getMnemonic: () => Promise; + getMnemonic: (source?: string | undefined) => Promise; /** * Waits for the extension to be unlocked. @@ -128,7 +133,11 @@ export function getBip44EntropyImplementation({ const params = args.params as GetBip44EntropyParams; const node = await BIP44CoinTypeNode.fromDerivationPath( - [await getMnemonic(), `bip32:44'`, `bip32:${params.coinType}'`], + [ + await getMnemonic(params.source), + `bip32:44'`, + `bip32:${params.coinType}'`, + ], 'mainnet', getClientCryptography(), ); diff --git a/packages/snaps-rpc-methods/src/restricted/getEntropy.test.ts b/packages/snaps-rpc-methods/src/restricted/getEntropy.test.ts index b59be5f495..5edc4c266b 100644 --- a/packages/snaps-rpc-methods/src/restricted/getEntropy.test.ts +++ b/packages/snaps-rpc-methods/src/restricted/getEntropy.test.ts @@ -73,6 +73,43 @@ describe('getEntropyImplementation', () => { ); }); + it('calls `getMnemonic` with a different entropy source', async () => { + const getMnemonic = jest + .fn() + .mockImplementation(() => TEST_SECRET_RECOVERY_PHRASE_BYTES); + + const getUnlockPromise = jest.fn(); + const getClientCryptography = jest.fn().mockReturnValue({}); + + const methodHooks = { + getMnemonic, + getUnlockPromise, + getClientCryptography, + }; + + const implementation = getEntropyBuilder.specificationBuilder({ + methodHooks, + }).methodImplementation; + + const result = await implementation({ + method: 'snap_getEntropy', + params: { + version: 1, + salt: 'foo', + source: 'source-id', + }, + context: { + origin: MOCK_SNAP_ID, + }, + }); + + expect(result).toBe( + '0x6d8e92de419401c7da3cedd5f60ce5635b26059c2a4a8003877fec83653a4921', + ); + + expect(getMnemonic).toHaveBeenCalledWith('source-id'); + }); + it('uses custom client cryptography functions', async () => { const getUnlockPromise = jest.fn().mockResolvedValue(undefined); const getMnemonic = jest diff --git a/packages/snaps-rpc-methods/src/restricted/getEntropy.ts b/packages/snaps-rpc-methods/src/restricted/getEntropy.ts index b5f685cc2c..b872a363ff 100644 --- a/packages/snaps-rpc-methods/src/restricted/getEntropy.ts +++ b/packages/snaps-rpc-methods/src/restricted/getEntropy.ts @@ -33,6 +33,7 @@ type GetEntropySpecification = ValidPermissionSpecification<{ export const GetEntropyArgsStruct = object({ version: literal(1), salt: optional(string()), + source: optional(string()), }); /** @@ -74,9 +75,14 @@ export const getEntropyBuilder = Object.freeze({ export type GetEntropyHooks = { /** - * @returns The mnemonic of the user's primary keyring. + * Get the mnemonic of the provided source. If no source is provided, the + * mnemonic of the primary keyring will be returned. + * + * @param source - The optional ID of the source to get the mnemonic of. + * @returns The mnemonic of the provided source, or the default source if no + * source is provided. */ - getMnemonic: () => Promise; + getMnemonic: (source?: string | undefined) => Promise; /** * Waits for the extension to be unlocked. @@ -130,7 +136,7 @@ function getEntropyImplementation({ ); await getUnlockPromise(true); - const mnemonicPhrase = await getMnemonic(); + const mnemonicPhrase = await getMnemonic(params.source); return deriveEntropy({ input: origin, diff --git a/packages/snaps-sdk/src/types/methods/get-bip32-entropy.ts b/packages/snaps-sdk/src/types/methods/get-bip32-entropy.ts index d875c5342b..791be9a9c0 100644 --- a/packages/snaps-sdk/src/types/methods/get-bip32-entropy.ts +++ b/packages/snaps-sdk/src/types/methods/get-bip32-entropy.ts @@ -4,11 +4,15 @@ import type { Bip32Entropy } from '../permissions'; /** * The request parameters for the `snap_getBip32Entropy` method. - * - * @property path - The BIP-32 path to derive the entropy from. - * @property curve - The curve to use when deriving the entropy. */ -export type GetBip32EntropyParams = Bip32Entropy; +export type GetBip32EntropyParams = Bip32Entropy & { + /** + * The ID of the entropy source to use. If not specified, the primary entropy + * source will be used. For a list of available entropy sources, see the + * `snap_listEntropySources` method. + */ + source?: string | undefined; +}; /** * The result returned by the `snap_getBip32Entropy` method. diff --git a/packages/snaps-sdk/src/types/methods/get-bip32-public-key.ts b/packages/snaps-sdk/src/types/methods/get-bip32-public-key.ts index 76af535fa1..53ed243d53 100644 --- a/packages/snaps-sdk/src/types/methods/get-bip32-public-key.ts +++ b/packages/snaps-sdk/src/types/methods/get-bip32-public-key.ts @@ -2,14 +2,19 @@ import type { Bip32Entropy } from '../permissions'; /** * The request parameters for the `snap_getBip32PublicKey` method. - * - * @property path - The BIP-32 path to derive the public key from. - * @property curve - The curve to use when deriving the public key. - * @property compressed - Whether to return the compressed public key. Defaults - * to `false`. */ export type GetBip32PublicKeyParams = Bip32Entropy & { + /** + * Whether to return the compressed public key. Defaults to `false`. + */ compressed?: boolean; + + /** + * The ID of the entropy source to use. If not specified, the primary entropy + * source will be used. For a list of available entropy sources, see the + * `snap_listEntropySources` method. + */ + source?: string | undefined; }; /** diff --git a/packages/snaps-sdk/src/types/methods/get-bip44-entropy.ts b/packages/snaps-sdk/src/types/methods/get-bip44-entropy.ts index b03598ecef..ec3ff140e1 100644 --- a/packages/snaps-sdk/src/types/methods/get-bip44-entropy.ts +++ b/packages/snaps-sdk/src/types/methods/get-bip44-entropy.ts @@ -4,10 +4,15 @@ import type { Bip44Entropy } from '../permissions'; /** * The request parameters for the `snap_getBip44Entropy` method. - * - * @property coinType - The BIP-44 coin type to derive the entropy from. */ -export type GetBip44EntropyParams = Bip44Entropy; +export type GetBip44EntropyParams = Bip44Entropy & { + /** + * The ID of the entropy source to use. If not specified, the primary entropy + * source will be used. For a list of available entropy sources, see the + * `snap_listEntropySources` method. + */ + source?: string | undefined; +}; /** * The result returned by the `snap_getBip44Entropy` method. diff --git a/packages/snaps-sdk/src/types/methods/get-entropy.ts b/packages/snaps-sdk/src/types/methods/get-entropy.ts index cfb3ffb44f..44b14bcb73 100644 --- a/packages/snaps-sdk/src/types/methods/get-entropy.ts +++ b/packages/snaps-sdk/src/types/methods/get-entropy.ts @@ -2,14 +2,25 @@ import type { Hex } from '@metamask/utils'; /** * The request parameters for the `snap_getEntropy` method. - * - * @property version - The version of the entropy to retrieve. This is used for - * backwards compatibility. As of now, only version 1 is supported. - * @property salt - The optional salt to use when deriving the entropy. */ export type GetEntropyParams = { + /** + * The version of the entropy to retrieve. This is used for backwards + * compatibility. As of now, only version 1 is supported. + */ version: 1; - salt?: string; + + /** + * The optional salt to use when deriving the entropy. + */ + salt?: string | undefined; + + /** + * The ID of the entropy source to use. If not specified, the primary entropy + * source will be used. For a list of available entropy sources, see the + * `snap_listEntropySources` method. + */ + source?: string | undefined; }; /** From a6367db793fc3bcc278290c1060efccc97a83182 Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Tue, 25 Feb 2025 17:35:50 +0100 Subject: [PATCH 3/5] Update entropy example and test-snaps to allow specifying entropy source --- .../packages/get-entropy/snap.config.ts | 2 +- .../packages/get-entropy/snap.manifest.json | 2 +- .../get-entropy/src/{index.ts => index.tsx} | 36 ++++++----- .../packages/get-entropy/src/types.ts | 6 ++ .../packages/get-entropy/src/utils.ts | 39 +++++++++++- packages/test-snaps/src/api.ts | 1 + .../features/snaps/get-entropy/GetEntropy.tsx | 8 ++- .../get-entropy/components/EntropySources.tsx | 60 +++++++++++++++++++ .../get-entropy/components/SignMessage.tsx | 9 ++- .../snaps/get-entropy/components/index.ts | 1 + .../features/snaps/get-entropy/hooks/index.ts | 1 + .../get-entropy/hooks/useEntropySources.ts | 23 +++++++ 12 files changed, 167 insertions(+), 21 deletions(-) rename packages/examples/packages/get-entropy/src/{index.ts => index.tsx} (66%) create mode 100644 packages/test-snaps/src/features/snaps/get-entropy/components/EntropySources.tsx create mode 100644 packages/test-snaps/src/features/snaps/get-entropy/hooks/index.ts create mode 100644 packages/test-snaps/src/features/snaps/get-entropy/hooks/useEntropySources.ts diff --git a/packages/examples/packages/get-entropy/snap.config.ts b/packages/examples/packages/get-entropy/snap.config.ts index 78ad668868..c84abc2f6f 100644 --- a/packages/examples/packages/get-entropy/snap.config.ts +++ b/packages/examples/packages/get-entropy/snap.config.ts @@ -2,7 +2,7 @@ import type { SnapConfig } from '@metamask/snaps-cli'; import { resolve } from 'path'; const config: SnapConfig = { - input: resolve(__dirname, 'src/index.ts'), + input: resolve(__dirname, 'src/index.tsx'), server: { port: 8009, }, diff --git a/packages/examples/packages/get-entropy/snap.manifest.json b/packages/examples/packages/get-entropy/snap.manifest.json index e15c1a34fd..977ef829bf 100644 --- a/packages/examples/packages/get-entropy/snap.manifest.json +++ b/packages/examples/packages/get-entropy/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snaps.git" }, "source": { - "shasum": "lmkCL1vKbBcM9oIiHMkfGq1P7CATFx/fksf+kvlv+eU=", + "shasum": "RAiYJUhEOg3GuNxpzlXbK6OVvBgLTtaS/R14t9oagY8=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/examples/packages/get-entropy/src/index.ts b/packages/examples/packages/get-entropy/src/index.tsx similarity index 66% rename from packages/examples/packages/get-entropy/src/index.ts rename to packages/examples/packages/get-entropy/src/index.tsx index 4a680d2e07..5598dea4cc 100644 --- a/packages/examples/packages/get-entropy/src/index.ts +++ b/packages/examples/packages/get-entropy/src/index.tsx @@ -1,18 +1,15 @@ import type { OnRpcRequestHandler } from '@metamask/snaps-sdk'; import { DialogType, - panel, - text, - heading, - copyable, - UserRejectedRequestError, MethodNotFoundError, + UserRejectedRequestError, } from '@metamask/snaps-sdk'; +import { Box, Copyable, Heading, Text } from '@metamask/snaps-sdk/jsx'; import { bytesToHex, stringToBytes } from '@metamask/utils'; import { sign } from '@noble/bls12-381'; import type { SignMessageParams } from './types'; -import { getEntropy } from './utils'; +import { getEntropy, getEntropySourceName } from './utils'; /** * Handle incoming JSON-RPC requests from the dapp, sent through the @@ -31,19 +28,22 @@ import { getEntropy } from './utils'; export const onRpcRequest: OnRpcRequestHandler = async ({ request }) => { switch (request.method) { case 'signMessage': { - const { message, salt } = request.params as SignMessageParams; + const { message, salt, source } = request.params as SignMessageParams; const approved = await snap.request({ method: 'snap_dialog', params: { type: DialogType.Confirmation, - content: panel([ - heading('Signature request'), - text( - 'Do you want to sign the following message with snap entropy?', - ), - copyable(message), - ]), + content: ( + + Signature request + + Do you want to sign the following message with Snap entropy, and + the entropy source "{await getEntropySourceName(source)}"? + + + + ), }, }); @@ -51,11 +51,17 @@ export const onRpcRequest: OnRpcRequestHandler = async ({ request }) => { throw new UserRejectedRequestError(); } - const privateKey = await getEntropy(salt); + const privateKey = await getEntropy(salt, source); const newLocal = await sign(stringToBytes(message), privateKey); return bytesToHex(newLocal); } + case 'getEntropySources': { + return await snap.request({ + method: 'snap_listEntropySources', + }); + } + default: throw new MethodNotFoundError({ method: request.method }); } diff --git a/packages/examples/packages/get-entropy/src/types.ts b/packages/examples/packages/get-entropy/src/types.ts index c2f9506046..234f007bb1 100644 --- a/packages/examples/packages/get-entropy/src/types.ts +++ b/packages/examples/packages/get-entropy/src/types.ts @@ -15,4 +15,10 @@ export type SignMessageParams = { * Defaults to "Signing key". */ salt?: string; + + /** + * The entropy source to use for the signature. If not provided, the primary + * entropy source will be used. + */ + source?: string | undefined; }; diff --git a/packages/examples/packages/get-entropy/src/utils.ts b/packages/examples/packages/get-entropy/src/utils.ts index 3008ed4693..49cf0f4a94 100644 --- a/packages/examples/packages/get-entropy/src/utils.ts +++ b/packages/examples/packages/get-entropy/src/utils.ts @@ -13,17 +13,54 @@ import { remove0x } from '@metamask/utils'; * * @param salt - The salt to use for the entropy derivation. Using a different * salt will result in completely different entropy being generated. + * @param source - The entropy source to use for the entropy derivation. If not + * provided, the primary entropy source will be used. * @returns The generated entropy, without the leading "0x". * @see https://docs.metamask.io/snaps/reference/rpc-api/#snap_getentropy */ -export async function getEntropy(salt = 'Signing key') { +export async function getEntropy( + salt = 'Signing key', + source?: string | undefined, +) { const entropy = await snap.request({ method: 'snap_getEntropy', params: { version: 1, salt, + source, }, }); return remove0x(entropy); } + +/** + * Get the name of an entropy source by its ID using the + * `snap_listEntropySources` JSON-RPC method. + * + * If the ID is not provided, the name of the primary entropy source will be + * returned. + * + * @param id - The ID of the entropy source. + * @returns The name of the entropy source. + */ +export async function getEntropySourceName(id?: string | undefined) { + if (id) { + const sources = await snap.request({ + method: 'snap_listEntropySources', + }); + + const source = sources.find((item) => item.id === id); + if (source) { + if (source.name.length > 0) { + return source.name; + } + + return source.id; + } + + return 'unknown source'; + } + + return 'primary source'; +} diff --git a/packages/test-snaps/src/api.ts b/packages/test-snaps/src/api.ts index 4c4f458d6e..681a5d3476 100644 --- a/packages/test-snaps/src/api.ts +++ b/packages/test-snaps/src/api.ts @@ -16,6 +16,7 @@ declare global { export enum Tag { Accounts = 'Accounts', + EntropySources = 'Entropy Sources', InstalledSnaps = 'Installed Snaps', TestState = 'Test State', UnencryptedTestState = 'Unencrypted Test State', diff --git a/packages/test-snaps/src/features/snaps/get-entropy/GetEntropy.tsx b/packages/test-snaps/src/features/snaps/get-entropy/GetEntropy.tsx index 8db568202e..ea2959b877 100644 --- a/packages/test-snaps/src/features/snaps/get-entropy/GetEntropy.tsx +++ b/packages/test-snaps/src/features/snaps/get-entropy/GetEntropy.tsx @@ -1,6 +1,7 @@ import type { FunctionComponent } from 'react'; +import { useState } from 'react'; -import { SignMessage } from './components'; +import { EntropySources, SignMessage } from './components'; import { GET_ENTROPY_PORT, GET_ENTROPY_SNAP_ID, @@ -9,6 +10,8 @@ import { import { Snap } from '../../../components'; export const GetEntropy: FunctionComponent = () => { + const [source, setSource] = useState(undefined); + return ( { version={GET_ENTROPY_VERSION} testId="GetEntropySnap" > - + + ); }; diff --git a/packages/test-snaps/src/features/snaps/get-entropy/components/EntropySources.tsx b/packages/test-snaps/src/features/snaps/get-entropy/components/EntropySources.tsx new file mode 100644 index 0000000000..60ef77b688 --- /dev/null +++ b/packages/test-snaps/src/features/snaps/get-entropy/components/EntropySources.tsx @@ -0,0 +1,60 @@ +import type { EntropySource } from '@metamask/snaps-sdk'; +import type { ChangeEvent, FunctionComponent } from 'react'; + +import { Result } from '../../../../components'; +import { useEntropySources } from '../hooks'; + +export type EntropySourcesProps = { + onChange: (source: string) => void; +}; + +/** + * Get the name of the source. + * + * @param source - The source. + * @returns The name of the source. + */ +function getSourceName(source: EntropySource) { + const name = source.name.length === 0 ? source.id : source.name; + + if (source.primary) { + return `${name} (primary)`; + } + + return name; +} + +export const EntropySources: FunctionComponent = ({ + onChange, +}) => { + const entropySources = useEntropySources(); + + const handleChange = (event: ChangeEvent) => { + onChange(event.target.value); + }; + + return ( + <> +

Entropy source

+ + +
+          {JSON.stringify(entropySources, null, 2)}
+        
+
+ + ); +}; diff --git a/packages/test-snaps/src/features/snaps/get-entropy/components/SignMessage.tsx b/packages/test-snaps/src/features/snaps/get-entropy/components/SignMessage.tsx index aefddeec91..dccabed095 100644 --- a/packages/test-snaps/src/features/snaps/get-entropy/components/SignMessage.tsx +++ b/packages/test-snaps/src/features/snaps/get-entropy/components/SignMessage.tsx @@ -8,7 +8,13 @@ import { Result } from '../../../../components'; import { getSnapId } from '../../../../utils'; import { GET_ENTROPY_SNAP_ID, GET_ENTROPY_PORT } from '../constants'; -export const SignMessage: FunctionComponent = () => { +export type SignMessageProps = { + source: string | undefined; +}; + +export const SignMessage: FunctionComponent = ({ + source, +}) => { const [message, setMessage] = useState(''); const [invokeSnap, { isLoading, data, error }] = useInvokeMutation(); @@ -24,6 +30,7 @@ export const SignMessage: FunctionComponent = () => { method: 'signMessage', params: { message, + ...(source !== undefined && { source }), }, }).catch(logError); }; diff --git a/packages/test-snaps/src/features/snaps/get-entropy/components/index.ts b/packages/test-snaps/src/features/snaps/get-entropy/components/index.ts index bc16f14a77..66244c0ab3 100644 --- a/packages/test-snaps/src/features/snaps/get-entropy/components/index.ts +++ b/packages/test-snaps/src/features/snaps/get-entropy/components/index.ts @@ -1 +1,2 @@ +export * from './EntropySources'; export * from './SignMessage'; diff --git a/packages/test-snaps/src/features/snaps/get-entropy/hooks/index.ts b/packages/test-snaps/src/features/snaps/get-entropy/hooks/index.ts new file mode 100644 index 0000000000..63cc9534fc --- /dev/null +++ b/packages/test-snaps/src/features/snaps/get-entropy/hooks/index.ts @@ -0,0 +1 @@ +export * from './useEntropySources'; diff --git a/packages/test-snaps/src/features/snaps/get-entropy/hooks/useEntropySources.ts b/packages/test-snaps/src/features/snaps/get-entropy/hooks/useEntropySources.ts new file mode 100644 index 0000000000..dbbf871db0 --- /dev/null +++ b/packages/test-snaps/src/features/snaps/get-entropy/hooks/useEntropySources.ts @@ -0,0 +1,23 @@ +import type { EntropySource } from '@metamask/snaps-sdk'; + +import { Tag, useInvokeQuery } from '../../../../api'; +import { getSnapId, useInstalled } from '../../../../utils'; +import { GET_ENTROPY_PORT, GET_ENTROPY_SNAP_ID } from '../constants'; + +export const useEntropySources = () => { + const snapId = getSnapId(GET_ENTROPY_SNAP_ID, GET_ENTROPY_PORT); + const isInstalled = useInstalled(snapId); + + const { data } = useInvokeQuery<{ data: EntropySource[] }>( + { + snapId, + method: 'getEntropySources', + tags: [Tag.EntropySources], + }, + { + skip: !isInstalled, + }, + ); + + return data; +}; From a4bbcac472cf11512ae8f76d356c5b63f65d1e6b Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Tue, 25 Feb 2025 20:55:14 +0100 Subject: [PATCH 4/5] Add SIP-30 support to `snaps-simulation` --- .../examples/packages/get-entropy/README.md | 2 +- .../packages/get-entropy/src/index.test.ts | 80 ---------- .../packages/get-entropy/src/index.test.tsx | 143 ++++++++++++++++++ packages/snaps-simulation/src/constants.ts | 7 + .../methods/hooks/get-entropy-sources.test.ts | 16 ++ .../src/methods/hooks/get-entropy-sources.ts | 25 +++ .../src/methods/hooks/get-mnemonic.test.ts | 45 ++++++ .../src/methods/hooks/get-mnemonic.ts | 29 ++++ .../src/methods/hooks/index.ts | 2 + packages/snaps-simulation/src/simulation.ts | 22 ++- packages/snaps-simulation/src/types.ts | 7 +- packages/test-snaps/package.json | 1 + yarn.lock | 1 + 13 files changed, 290 insertions(+), 90 deletions(-) delete mode 100644 packages/examples/packages/get-entropy/src/index.test.ts create mode 100644 packages/examples/packages/get-entropy/src/index.test.tsx create mode 100644 packages/snaps-simulation/src/methods/hooks/get-entropy-sources.test.ts create mode 100644 packages/snaps-simulation/src/methods/hooks/get-entropy-sources.ts create mode 100644 packages/snaps-simulation/src/methods/hooks/get-mnemonic.test.ts create mode 100644 packages/snaps-simulation/src/methods/hooks/get-mnemonic.ts diff --git a/packages/examples/packages/get-entropy/README.md b/packages/examples/packages/get-entropy/README.md index ec8339e057..594d25680d 100644 --- a/packages/examples/packages/get-entropy/README.md +++ b/packages/examples/packages/get-entropy/README.md @@ -32,4 +32,4 @@ JSON-RPC methods: `BLS12-381` elliptic curve to sign the message. For more information, you can refer to -[the end-to-end tests](./src/index.test.ts). +[the end-to-end tests](./src/index.test.tsx). diff --git a/packages/examples/packages/get-entropy/src/index.test.ts b/packages/examples/packages/get-entropy/src/index.test.ts deleted file mode 100644 index 3c76ae17e7..0000000000 --- a/packages/examples/packages/get-entropy/src/index.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { expect } from '@jest/globals'; -import { installSnap } from '@metamask/snaps-jest'; -import { copyable, heading, panel, text } from '@metamask/snaps-sdk'; - -describe('onRpcRequest', () => { - it('throws an error if the requested method does not exist', async () => { - const { request } = await installSnap(); - - const response = await request({ - method: 'foo', - }); - - expect(response).toRespondWithError({ - code: -32601, - message: 'The method does not exist / is not available.', - stack: expect.any(String), - data: { - method: 'foo', - cause: null, - }, - }); - }); - - describe('signMessage', () => { - it('signs a message with the snap entropy', async () => { - const { request } = await installSnap(); - - const response = request({ - method: 'signMessage', - params: { - message: 'Hello, world!', - }, - }); - - const ui = await response.getInterface(); - expect(ui).toRender( - panel([ - heading('Signature request'), - text('Do you want to sign the following message with snap entropy?'), - copyable('Hello, world!'), - ]), - ); - - // TODO(ritave): Fix types in SnapInterface - await (ui as any).ok(); - - expect(await response).toRespondWith( - '0x8b3f38050fb60fffd2e0e2ef04504b09e8f0ff46e25896cfd87ced67a5a76ac75c534c9bafbf6f38b6e50b969e1239c80916040de30a3f9ee973d6a3281d39624e7d463b2a5bc0165764b0b4ce8ad009352076c54a202a8c63554b00a46872dc', - ); - }); - - it('signs a message with a different salt', async () => { - const { request } = await installSnap(); - - const response = request({ - method: 'signMessage', - params: { - message: 'Hello, world!', - salt: 'Other salt', - }, - }); - - const ui = await response.getInterface(); - expect(ui).toRender( - panel([ - heading('Signature request'), - text('Do you want to sign the following message with snap entropy?'), - copyable('Hello, world!'), - ]), - ); - - // TODO(ritave): Fix types in SnapInterface - await (ui as any).ok(); - - expect(await response).toRespondWith( - '0x877530880baa4d1fc1fca749f5a26123275ffaa617505cae8f3da4a58d06ea43b7123d4575331dd15ffd5103ed2091050af0aa715adc3b7e122c8e07a97b7fce76c34e8e2ef0037b36015795e0ae530fed264ffb4b33bd47149af192f4c51411', - ); - }); - }); -}); diff --git a/packages/examples/packages/get-entropy/src/index.test.tsx b/packages/examples/packages/get-entropy/src/index.test.tsx new file mode 100644 index 0000000000..12b0da8b87 --- /dev/null +++ b/packages/examples/packages/get-entropy/src/index.test.tsx @@ -0,0 +1,143 @@ +import { expect } from '@jest/globals'; +import { installSnap } from '@metamask/snaps-jest'; +import { Box, Copyable, Heading, Text } from '@metamask/snaps-sdk/jsx'; +import { assert } from '@metamask/utils'; + +describe('onRpcRequest', () => { + it('throws an error if the requested method does not exist', async () => { + const { request } = await installSnap(); + + const response = await request({ + method: 'foo', + }); + + expect(response).toRespondWithError({ + code: -32601, + message: 'The method does not exist / is not available.', + stack: expect.any(String), + data: { + method: 'foo', + cause: null, + }, + }); + }); + + describe('signMessage', () => { + it('signs a message with the snap entropy', async () => { + const { request } = await installSnap(); + + const response = request({ + method: 'signMessage', + params: { + message: 'Hello, world!', + }, + }); + + const ui = await response.getInterface(); + expect(ui).toRender( + + Signature request + + Do you want to sign the following message with Snap entropy, and the + entropy source "{'Primary source'}"? + + + , + ); + + assert(ui.type === 'confirmation'); + await ui.ok(); + + expect(await response).toRespondWith( + '0x8b3f38050fb60fffd2e0e2ef04504b09e8f0ff46e25896cfd87ced67a5a76ac75c534c9bafbf6f38b6e50b969e1239c80916040de30a3f9ee973d6a3281d39624e7d463b2a5bc0165764b0b4ce8ad009352076c54a202a8c63554b00a46872dc', + ); + }); + + it('signs a message with a different salt', async () => { + const { request } = await installSnap(); + + const response = request({ + method: 'signMessage', + params: { + message: 'Hello, world!', + salt: 'Other salt', + }, + }); + + const ui = await response.getInterface(); + expect(ui).toRender( + + Signature request + + Do you want to sign the following message with Snap entropy, and the + entropy source "{'Primary source'}"? + + + , + ); + + assert(ui.type === 'confirmation'); + await ui.ok(); + + expect(await response).toRespondWith( + '0x877530880baa4d1fc1fca749f5a26123275ffaa617505cae8f3da4a58d06ea43b7123d4575331dd15ffd5103ed2091050af0aa715adc3b7e122c8e07a97b7fce76c34e8e2ef0037b36015795e0ae530fed264ffb4b33bd47149af192f4c51411', + ); + }); + + it('signs a message with a different entropy source', async () => { + const { request } = await installSnap(); + + const response = request({ + method: 'signMessage', + params: { + message: 'Hello, world!', + source: 'alternative', + }, + }); + + const ui = await response.getInterface(); + expect(ui).toRender( + + Signature request + + Do you want to sign the following message with Snap entropy, and the + entropy source "{'Alternative Secret Recovery Phrase'}"? + + + , + ); + + assert(ui.type === 'confirmation'); + await ui.ok(); + + expect(await response).toRespondWith( + '0xad9bff2fc10e412b1dc3e2f88bc2c0da3c994c4a75cd59b1a92ef18bfd24af459aad5a6355d3030cf44cd52486dc274419177820fdc44b86842a043a3da5aa3a5c07990265891dc871a7cd341b1771282aa042a024810f17ecb6929d731a4013', + ); + }); + }); + + describe('getEntropySources', () => { + it('returns the entropy sources', async () => { + const { request } = await installSnap(); + + const response = request({ + method: 'getEntropySources', + }); + + expect(await response).toRespondWith([ + { + id: 'default', + name: 'Default Secret Recovery Phrase', + type: 'mnemonic', + primary: true, + }, + { + id: 'alternative', + name: 'Alternative Secret Recovery Phrase', + type: 'mnemonic', + primary: false, + }, + ]); + }); + }); +}); diff --git a/packages/snaps-simulation/src/constants.ts b/packages/snaps-simulation/src/constants.ts index 892747963f..b69d4c817a 100644 --- a/packages/snaps-simulation/src/constants.ts +++ b/packages/snaps-simulation/src/constants.ts @@ -5,6 +5,13 @@ export const DEFAULT_SRP = 'test test test test test test test test test test test ball'; +/** + * An alternative secret recovery phrase that is used for testing purposes. Do + * not use this to store any real funds! + */ +export const DEFAULT_ALTERNATIVE_SRP = + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'; + /** * The default locale. */ diff --git a/packages/snaps-simulation/src/methods/hooks/get-entropy-sources.test.ts b/packages/snaps-simulation/src/methods/hooks/get-entropy-sources.test.ts new file mode 100644 index 0000000000..ea24559a75 --- /dev/null +++ b/packages/snaps-simulation/src/methods/hooks/get-entropy-sources.test.ts @@ -0,0 +1,16 @@ +import { getGetEntropySourcesImplementation } from './get-entropy-sources'; + +describe('getGetEntropySourcesImplementation', () => { + it('returns the implementation of the `getEntropySources` hook', async () => { + const fn = getGetEntropySourcesImplementation(); + + expect(fn()).toStrictEqual([ + { + id: 'entropy-source-1', + name: 'Entropy Source 1', + type: 'mnemonic', + primary: true, + }, + ]); + }); +}); diff --git a/packages/snaps-simulation/src/methods/hooks/get-entropy-sources.ts b/packages/snaps-simulation/src/methods/hooks/get-entropy-sources.ts new file mode 100644 index 0000000000..56c7e0f6d9 --- /dev/null +++ b/packages/snaps-simulation/src/methods/hooks/get-entropy-sources.ts @@ -0,0 +1,25 @@ +/** + * Get the implementation of the `getEntropySources` hook. + * + * @returns The implementation of the `getEntropySources` hook. Right now, it + * only returns a single hard coded entropy source. In the future, it could + * return a configurable list of entropy sources. + */ +export function getGetEntropySourcesImplementation() { + return () => { + return [ + { + id: 'default', + name: 'Default Secret Recovery Phrase', + type: 'mnemonic' as const, + primary: true, + }, + { + id: 'alternative', + name: 'Alternative Secret Recovery Phrase', + type: 'mnemonic' as const, + primary: false, + }, + ]; + }; +} diff --git a/packages/snaps-simulation/src/methods/hooks/get-mnemonic.test.ts b/packages/snaps-simulation/src/methods/hooks/get-mnemonic.test.ts new file mode 100644 index 0000000000..be7f87440f --- /dev/null +++ b/packages/snaps-simulation/src/methods/hooks/get-mnemonic.test.ts @@ -0,0 +1,45 @@ +import { mnemonicPhraseToBytes } from '@metamask/key-tree'; + +import { getGetMnemonicImplementation } from './get-mnemonic'; +import { + DEFAULT_ALTERNATIVE_SRP, + DEFAULT_SRP, +} from '@metamask/snaps-simulation'; + +describe('getGetMnemonicImplementation', () => { + it('returns the default mnemonic phrase', async () => { + const getMnemonic = getGetMnemonicImplementation(); + expect(await getMnemonic()).toStrictEqual( + mnemonicPhraseToBytes(DEFAULT_SRP), + ); + + expect(await getMnemonic('default')).toStrictEqual( + mnemonicPhraseToBytes(DEFAULT_SRP), + ); + }); + + it('returns the provided default mnemonic phrase', async () => { + const getMnemonic = getGetMnemonicImplementation(DEFAULT_ALTERNATIVE_SRP); + expect(await getMnemonic()).toStrictEqual( + mnemonicPhraseToBytes(DEFAULT_ALTERNATIVE_SRP), + ); + + expect(await getMnemonic('default')).toStrictEqual( + mnemonicPhraseToBytes(DEFAULT_ALTERNATIVE_SRP), + ); + }); + + it('returns the alternative mnemonic phrase', async () => { + const getMnemonic = getGetMnemonicImplementation(); + expect(await getMnemonic('alternative')).toStrictEqual( + mnemonicPhraseToBytes(DEFAULT_ALTERNATIVE_SRP), + ); + }); + + it('throws an error for an unknown entropy source', async () => { + const getMnemonic = getGetMnemonicImplementation(); + await expect(getMnemonic('unknown')).rejects.toThrow( + 'Unknown entropy source: "unknown".', + ); + }); +}); diff --git a/packages/snaps-simulation/src/methods/hooks/get-mnemonic.ts b/packages/snaps-simulation/src/methods/hooks/get-mnemonic.ts new file mode 100644 index 0000000000..6c0a615df0 --- /dev/null +++ b/packages/snaps-simulation/src/methods/hooks/get-mnemonic.ts @@ -0,0 +1,29 @@ +import { mnemonicPhraseToBytes } from '@metamask/key-tree'; + +import { DEFAULT_ALTERNATIVE_SRP, DEFAULT_SRP } from '../../constants'; + +/** + * Get the implementation of the `getMnemonic` method. + * + * @param defaultSecretRecoveryPhrase - The default secret recovery phrase to + * use. + * @returns The implementation of the `getMnemonic` method. + */ +export function getGetMnemonicImplementation( + defaultSecretRecoveryPhrase: string = DEFAULT_SRP, +) { + return async (source?: string | undefined): Promise => { + if (!source) { + return mnemonicPhraseToBytes(defaultSecretRecoveryPhrase); + } + + switch (source) { + case 'default': + return mnemonicPhraseToBytes(defaultSecretRecoveryPhrase); + case 'alternative': + return mnemonicPhraseToBytes(DEFAULT_ALTERNATIVE_SRP); + default: + throw new Error(`Unknown entropy source: "${source}".`); + } + }; +} diff --git a/packages/snaps-simulation/src/methods/hooks/index.ts b/packages/snaps-simulation/src/methods/hooks/index.ts index 4c73b7e698..940e90e24e 100644 --- a/packages/snaps-simulation/src/methods/hooks/index.ts +++ b/packages/snaps-simulation/src/methods/hooks/index.ts @@ -1,3 +1,5 @@ +export * from './get-entropy-sources'; +export * from './get-mnemonic'; export * from './get-preferences'; export * from './interface'; export * from './notifications'; diff --git a/packages/snaps-simulation/src/simulation.ts b/packages/snaps-simulation/src/simulation.ts index 48275c6bb7..99f1da4e19 100644 --- a/packages/snaps-simulation/src/simulation.ts +++ b/packages/snaps-simulation/src/simulation.ts @@ -4,10 +4,7 @@ import type { } from '@metamask/base-controller'; import { Messenger } from '@metamask/base-controller'; import { createEngineStream } from '@metamask/json-rpc-middleware-stream'; -import { - type CryptographicFunctions, - mnemonicPhraseToBytes, -} from '@metamask/key-tree'; +import type { CryptographicFunctions } from '@metamask/key-tree'; import { PhishingDetectorResultType } from '@metamask/phishing-controller'; import type { AbstractExecutionService } from '@metamask/snaps-controllers'; import { @@ -23,6 +20,7 @@ import type { InterfaceState, InterfaceContext, SnapId, + EntropySource, } from '@metamask/snaps-sdk'; import type { FetchedSnapFiles } from '@metamask/snaps-utils'; import { logError } from '@metamask/snaps-utils'; @@ -43,6 +41,8 @@ import { getPermittedClearSnapStateMethodImplementation, getPermittedGetSnapStateMethodImplementation, getPermittedUpdateSnapStateMethodImplementation, + getGetEntropySourcesImplementation, + getGetMnemonicImplementation, } from './methods/hooks'; import { createJsonRpcEngine } from './middleware'; import type { SimulationOptions, SimulationUserOptions } from './options'; @@ -103,9 +103,10 @@ export type RestrictedMiddlewareHooks = { /** * A hook that returns the user's secret recovery phrase. * + * @param source - The entropy source to get the mnemonic from. * @returns The user's secret recovery phrase. */ - getMnemonic: () => Promise; + getMnemonic: (source?: string | undefined) => Promise; /** * A hook that returns whether the client is locked or not. @@ -132,6 +133,13 @@ export type PermittedMiddlewareHooks = { */ hasPermission: (permissionName: string) => boolean; + /** + * A hook that returns the entropy sources available to the Snap. + * + * @returns The entropy sources available to the Snap. + */ + getEntropySources: () => EntropySource[]; + /** * A hook that returns a promise that resolves once the extension is unlocked. * @@ -373,8 +381,7 @@ export function getRestrictedHooks( options: SimulationOptions, ): RestrictedMiddlewareHooks { return { - getMnemonic: async () => - Promise.resolve(mnemonicPhraseToBytes(options.secretRecoveryPhrase)), + getMnemonic: getGetMnemonicImplementation(options.secretRecoveryPhrase), getIsLocked: () => false, getClientCryptography: () => ({}), }; @@ -434,6 +441,7 @@ export function getPermittedHooks( ...args, ), + getEntropySources: getGetEntropySourcesImplementation(), getSnapState: getPermittedGetSnapStateMethodImplementation(runSaga), updateSnapState: getPermittedUpdateSnapStateMethodImplementation(runSaga), clearSnapState: getPermittedClearSnapStateMethodImplementation(runSaga), diff --git a/packages/snaps-simulation/src/types.ts b/packages/snaps-simulation/src/types.ts index 21fc881fc8..9417ae57ec 100644 --- a/packages/snaps-simulation/src/types.ts +++ b/packages/snaps-simulation/src/types.ts @@ -288,10 +288,13 @@ export type DefaultSnapInterfaceWithoutFooter = ok(): Promise; }; -export type DefaultSnapInterface = +export type DefaultSnapInterface = ( | DefaultSnapInterfaceWithFooter | DefaultSnapInterfaceWithPartialFooter - | DefaultSnapInterfaceWithoutFooter; + | DefaultSnapInterfaceWithoutFooter +) & { + type?: never; +}; export type SnapInterface = ( | SnapAlertInterface diff --git a/packages/test-snaps/package.json b/packages/test-snaps/package.json index 7c9dcd492a..46bdc1cb52 100644 --- a/packages/test-snaps/package.json +++ b/packages/test-snaps/package.json @@ -69,6 +69,7 @@ "@metamask/protocol-example-snap": "workspace:^", "@metamask/send-flow-example-snap": "workspace:^", "@metamask/signature-insights-example-snap": "workspace:^", + "@metamask/snaps-sdk": "workspace:^", "@metamask/snaps-utils": "workspace:^", "@metamask/utils": "^11.2.0", "@metamask/wasm-example-snap": "workspace:^", diff --git a/yarn.lock b/yarn.lock index cd4955910a..9614155d29 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5933,6 +5933,7 @@ __metadata: "@metamask/providers": "npm:^20.0.0" "@metamask/send-flow-example-snap": "workspace:^" "@metamask/signature-insights-example-snap": "workspace:^" + "@metamask/snaps-sdk": "workspace:^" "@metamask/snaps-utils": "workspace:^" "@metamask/utils": "npm:^11.2.0" "@metamask/wasm-example-snap": "workspace:^" From 734b76dfa3611eb76ec4aee105f40f4edfee6102 Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Wed, 26 Feb 2025 10:41:34 +0100 Subject: [PATCH 5/5] Fix tests and lint issue --- .../examples/packages/get-entropy/snap.manifest.json | 2 +- .../examples/packages/get-entropy/src/index.test.tsx | 4 ++-- .../src/methods/hooks/get-entropy-sources.test.ts | 10 ++++++++-- .../src/methods/hooks/get-mnemonic.test.ts | 5 +---- 4 files changed, 12 insertions(+), 9 deletions(-) diff --git a/packages/examples/packages/get-entropy/snap.manifest.json b/packages/examples/packages/get-entropy/snap.manifest.json index 977ef829bf..7325653997 100644 --- a/packages/examples/packages/get-entropy/snap.manifest.json +++ b/packages/examples/packages/get-entropy/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snaps.git" }, "source": { - "shasum": "RAiYJUhEOg3GuNxpzlXbK6OVvBgLTtaS/R14t9oagY8=", + "shasum": "JHgX6Yu2QH0wnVJ67t+IUxNpOvGOhOwWip2b6rI3F4A=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/examples/packages/get-entropy/src/index.test.tsx b/packages/examples/packages/get-entropy/src/index.test.tsx index 12b0da8b87..0b4750ff0d 100644 --- a/packages/examples/packages/get-entropy/src/index.test.tsx +++ b/packages/examples/packages/get-entropy/src/index.test.tsx @@ -39,7 +39,7 @@ describe('onRpcRequest', () => { Signature request Do you want to sign the following message with Snap entropy, and the - entropy source "{'Primary source'}"? + entropy source "{'primary source'}"? , @@ -70,7 +70,7 @@ describe('onRpcRequest', () => { Signature request Do you want to sign the following message with Snap entropy, and the - entropy source "{'Primary source'}"? + entropy source "{'primary source'}"? , diff --git a/packages/snaps-simulation/src/methods/hooks/get-entropy-sources.test.ts b/packages/snaps-simulation/src/methods/hooks/get-entropy-sources.test.ts index ea24559a75..a84bb839d8 100644 --- a/packages/snaps-simulation/src/methods/hooks/get-entropy-sources.test.ts +++ b/packages/snaps-simulation/src/methods/hooks/get-entropy-sources.test.ts @@ -6,11 +6,17 @@ describe('getGetEntropySourcesImplementation', () => { expect(fn()).toStrictEqual([ { - id: 'entropy-source-1', - name: 'Entropy Source 1', + id: 'default', + name: 'Default Secret Recovery Phrase', type: 'mnemonic', primary: true, }, + { + id: 'alternative', + name: 'Alternative Secret Recovery Phrase', + type: 'mnemonic', + primary: false, + }, ]); }); }); diff --git a/packages/snaps-simulation/src/methods/hooks/get-mnemonic.test.ts b/packages/snaps-simulation/src/methods/hooks/get-mnemonic.test.ts index be7f87440f..a50c06db34 100644 --- a/packages/snaps-simulation/src/methods/hooks/get-mnemonic.test.ts +++ b/packages/snaps-simulation/src/methods/hooks/get-mnemonic.test.ts @@ -1,10 +1,7 @@ import { mnemonicPhraseToBytes } from '@metamask/key-tree'; import { getGetMnemonicImplementation } from './get-mnemonic'; -import { - DEFAULT_ALTERNATIVE_SRP, - DEFAULT_SRP, -} from '@metamask/snaps-simulation'; +import { DEFAULT_ALTERNATIVE_SRP, DEFAULT_SRP } from '../../constants'; describe('getGetMnemonicImplementation', () => { it('returns the default mnemonic phrase', async () => {