diff --git a/packages/keyring-api/src/api/v2/index.ts b/packages/keyring-api/src/api/v2/index.ts index cc137c23..0f450dca 100644 --- a/packages/keyring-api/src/api/v2/index.ts +++ b/packages/keyring-api/src/api/v2/index.ts @@ -1,6 +1,7 @@ export type * from './keyring'; export * from './keyring-capabilities'; export * from './keyring-type'; +export * from './keyring-rpc'; export * from './create-account'; export * from './export-account'; export * from './private-key'; diff --git a/packages/keyring-api/src/api/v2/keyring-rpc.test.ts b/packages/keyring-api/src/api/v2/keyring-rpc.test.ts new file mode 100644 index 00000000..bac57e90 --- /dev/null +++ b/packages/keyring-api/src/api/v2/keyring-rpc.test.ts @@ -0,0 +1,14 @@ +import { KeyringRpcV2Method, isKeyringRpcV2Method } from './keyring-rpc'; + +describe('isKeyringRpcV2Method', () => { + it.each(Object.values(KeyringRpcV2Method))( + 'returns true for: "%s"', + (method) => { + expect(isKeyringRpcV2Method(method)).toBe(true); + }, + ); + + it('returns false for unknown method', () => { + expect(isKeyringRpcV2Method('keyring_unknownMethod')).toBe(false); + }); +}); diff --git a/packages/keyring-api/src/api/v2/keyring-rpc.ts b/packages/keyring-api/src/api/v2/keyring-rpc.ts new file mode 100644 index 00000000..7856d68a --- /dev/null +++ b/packages/keyring-api/src/api/v2/keyring-rpc.ts @@ -0,0 +1,186 @@ +import { object, exactOptional, UuidStruct } from '@metamask/keyring-utils'; +import type { Infer } from '@metamask/superstruct'; +import { array, literal, number, string, union } from '@metamask/superstruct'; +import { JsonStruct } from '@metamask/utils'; + +import { CreateAccountOptionsStruct } from './create-account'; +import { + ExportAccountOptionsStruct, + PrivateKeyExportedAccountStruct, +} from './export-account'; +import type { KeyringV2 } from './keyring'; +import { KeyringAccountStruct } from '../account'; +import { KeyringRequestStruct } from '../request'; + +/** + * Keyring interface for keyring methods that can be invoked through + * RPC calls. + */ +export type KeyringRpcV2 = { + getAccounts: KeyringV2['getAccounts']; + getAccount: KeyringV2['getAccount']; + createAccounts: KeyringV2['createAccounts']; + deleteAccount: KeyringV2['deleteAccount']; + submitRequest: KeyringV2['submitRequest']; + exportAccount?: KeyringV2['exportAccount']; +}; + +/** + * Keyring RPC methods used by the API. + */ +export enum KeyringRpcV2Method { + GetAccounts = 'keyring_v2_getAccounts', + GetAccount = 'keyring_v2_getAccount', + CreateAccounts = 'keyring_v2_createAccounts', + DeleteAccount = 'keyring_v2_deleteAccount', + ExportAccount = 'keyring_v2_exportAccount', + SubmitRequest = 'keyring_v2_submitRequest', +} + +/** + * Check if a method is a keyring RPC method (v2). + * + * @param method - Method to check. + * @returns Whether the method is a keyring RPC method (v2). + */ +export function isKeyringRpcV2Method( + method: string, +): method is KeyringRpcV2Method { + return Object.values(KeyringRpcV2Method).includes( + method as KeyringRpcV2Method, + ); +} + +// ---------------------------------------------------------------------------- + +const CommonHeader = { + jsonrpc: literal('2.0'), + id: union([string(), number(), literal(null)]), +}; + +// ---------------------------------------------------------------------------- +// Get accounts + +export const GetAccountsV2RequestStruct = object({ + ...CommonHeader, + method: literal(`${KeyringRpcV2Method.GetAccounts}`), +}); + +export type GetAccountsV2Request = Infer; + +export const GetAccountsV2ResponseStruct = array(KeyringAccountStruct); + +export type GetAccountsV2Response = Infer; + +// ---------------------------------------------------------------------------- +// Get account + +export const GetAccountV2RequestStruct = object({ + ...CommonHeader, + method: literal(`${KeyringRpcV2Method.GetAccount}`), + params: object({ + id: UuidStruct, + }), +}); + +export type GetAccountV2Request = Infer; + +export const GetAccountV2ResponseStruct = KeyringAccountStruct; + +export type GetAccountV2Response = Infer; + +// ---------------------------------------------------------------------------- +// Create accounts + +export const CreateAccountsV2RequestStruct = object({ + ...CommonHeader, + method: literal(`${KeyringRpcV2Method.CreateAccounts}`), + params: CreateAccountOptionsStruct, +}); + +export type CreateAccountsV2Request = Infer< + typeof CreateAccountsV2RequestStruct +>; + +export const CreateAccountsV2ResponseStruct = array(KeyringAccountStruct); + +export type CreateAccountsV2Response = Infer< + typeof CreateAccountsV2ResponseStruct +>; + +// ---------------------------------------------------------------------------- +// Delete account + +export const DeleteAccountV2RequestStruct = object({ + ...CommonHeader, + method: literal(`${KeyringRpcV2Method.DeleteAccount}`), + params: object({ + id: UuidStruct, + }), +}); + +export type DeleteAccountV2Request = Infer; + +export const DeleteAccountV2ResponseStruct = literal(null); + +export type DeleteAccountV2Response = Infer< + typeof DeleteAccountV2ResponseStruct +>; + +// ---------------------------------------------------------------------------- +// Export account + +export const ExportAccountV2RequestStruct = object({ + ...CommonHeader, + method: literal(`${KeyringRpcV2Method.ExportAccount}`), + params: object({ + id: UuidStruct, + options: exactOptional(ExportAccountOptionsStruct), + }), +}); + +export type ExportAccountV2Request = Infer; + +export const ExportAccountV2ResponseStruct = PrivateKeyExportedAccountStruct; + +export type ExportAccountV2Response = Infer< + typeof ExportAccountV2ResponseStruct +>; + +// ---------------------------------------------------------------------------- +// Submit request + +export const SubmitRequestV2RequestStruct = object({ + ...CommonHeader, + method: literal(`${KeyringRpcV2Method.SubmitRequest}`), + params: KeyringRequestStruct, +}); + +export type SubmitRequestV2Request = Infer; + +export const SubmitRequestV2ResponseStruct = JsonStruct; + +export type SubmitRequestV2Response = Infer< + typeof SubmitRequestV2ResponseStruct +>; + +// ---------------------------------------------------------------------------- + +/** + * Keyring RPC requests. + */ +export type KeyringRpcV2Requests = + | GetAccountsV2Request + | GetAccountV2Request + | CreateAccountsV2Request + | DeleteAccountV2Request + | ExportAccountV2Request + | SubmitRequestV2Request; + +/** + * Extract the proper request type for a given `KeyringRpcV2Method`. + */ +export type KeyringRpcV2Request = Extract< + KeyringRpcV2Requests, + { method: `${RpcMethod}` } +>; diff --git a/packages/keyring-api/src/rpc.test.ts b/packages/keyring-api/src/rpc.test.ts index e2f0afde..ba007bb1 100644 --- a/packages/keyring-api/src/rpc.test.ts +++ b/packages/keyring-api/src/rpc.test.ts @@ -2,7 +2,7 @@ import { KeyringRpcMethod, isKeyringRpcMethod } from './rpc'; describe('isKeyringRpcMethod', () => { it.each(Object.values(KeyringRpcMethod))( - 'returns true for: KeyringRpcMethod.$s', + 'returns true for: "%s"', (method) => { expect(isKeyringRpcMethod(method)).toBe(true); }, diff --git a/packages/keyring-api/src/rpc.ts b/packages/keyring-api/src/rpc.ts index c3895674..c62b7daa 100644 --- a/packages/keyring-api/src/rpc.ts +++ b/packages/keyring-api/src/rpc.ts @@ -60,7 +60,7 @@ export enum KeyringRpcMethod { * @param method - Method to check. * @returns Whether the method is a keyring RPC method. */ -export function isKeyringRpcMethod(method: string): boolean { +export function isKeyringRpcMethod(method: string): method is KeyringRpcMethod { return Object.values(KeyringRpcMethod).includes(method as KeyringRpcMethod); } diff --git a/packages/keyring-internal-snap-client/src/KeyringInternalSnapClient.ts b/packages/keyring-internal-snap-client/src/KeyringInternalSnapClient.ts index 49b2c791..9f59cada 100644 --- a/packages/keyring-internal-snap-client/src/KeyringInternalSnapClient.ts +++ b/packages/keyring-internal-snap-client/src/KeyringInternalSnapClient.ts @@ -4,11 +4,12 @@ import type { KeyringResponseV1, } from '@metamask/keyring-internal-api'; import { SubmitRequestResponseV1Struct } from '@metamask/keyring-internal-api'; -import { KeyringClient, type Sender } from '@metamask/keyring-snap-client'; -import { strictMask, type JsonRpcRequest } from '@metamask/keyring-utils'; +import type { Sender } from '@metamask/keyring-snap-client'; +import { KeyringClient } from '@metamask/keyring-snap-client'; +import { strictMask } from '@metamask/keyring-utils'; import type { Messenger } from '@metamask/messenger'; import type { HandleSnapRequest } from '@metamask/snaps-controllers'; -import type { SnapId } from '@metamask/snaps-sdk'; +import type { JsonRpcRequest, SnapId } from '@metamask/snaps-sdk'; import type { HandlerType } from '@metamask/snaps-utils'; import type { Json } from '@metamask/utils'; @@ -28,7 +29,7 @@ export type KeyringInternalSnapClientMessenger = Messenger< * Implementation of the `Sender` interface that can be used to send requests * to a Snap through a `Messenger`. */ -class SnapControllerMessengerSender implements Sender { +export class SnapControllerMessengerSender implements Sender { readonly #snapId: SnapId; readonly #origin: string; diff --git a/packages/keyring-internal-snap-client/src/index.ts b/packages/keyring-internal-snap-client/src/index.ts index cbd73172..d30021c9 100644 --- a/packages/keyring-internal-snap-client/src/index.ts +++ b/packages/keyring-internal-snap-client/src/index.ts @@ -1 +1,2 @@ export * from './KeyringInternalSnapClient'; +export * from './v2'; diff --git a/packages/keyring-internal-snap-client/src/v2/KeyringInternalSnapClientV2.test.ts b/packages/keyring-internal-snap-client/src/v2/KeyringInternalSnapClientV2.test.ts new file mode 100644 index 00000000..9dcda80a --- /dev/null +++ b/packages/keyring-internal-snap-client/src/v2/KeyringInternalSnapClientV2.test.ts @@ -0,0 +1,82 @@ +import { KeyringRpcV2Method, type KeyringAccount } from '@metamask/keyring-api'; +import type { SnapId } from '@metamask/snaps-sdk'; + +import { KeyringInternalSnapClientV2 } from './KeyringInternalSnapClientV2'; +import type { KeyringInternalSnapClientMessenger } from '../KeyringInternalSnapClient'; + +const MOCK_ACCOUNT: KeyringAccount = { + id: '13f94041-6ae6-451f-a0fe-afdd2fda18a7', + address: '0xE9A74AACd7df8112911ca93260fC5a046f8a64Ae', + options: {}, + methods: [], + scopes: ['eip155:0'], + type: 'eip155:eoa', +}; + +describe('KeyringInternalSnapClientV2', () => { + const snapId = 'local:localhost:3000' as SnapId; + + const accountsList: KeyringAccount[] = [MOCK_ACCOUNT]; + + const messenger = { + call: jest.fn(), + }; + + describe('getAccounts', () => { + const request = { + snapId, + origin: 'metamask', + handler: 'onKeyringRequest', + request: { + id: expect.any(String), + jsonrpc: '2.0', + method: KeyringRpcV2Method.GetAccounts, + }, + }; + + it('calls the getAccounts method and return the result', async () => { + const client = new KeyringInternalSnapClientV2({ + messenger: messenger as unknown as KeyringInternalSnapClientMessenger, + snapId, + }); + + messenger.call.mockResolvedValue(accountsList); + const accounts = await client.getAccounts(); + expect(messenger.call).toHaveBeenCalledWith( + 'SnapController:handleRequest', + request, + ); + expect(accounts).toStrictEqual(accountsList); + }); + + it('calls the getAccounts method and return the result (withSnapId)', async () => { + const client = new KeyringInternalSnapClientV2({ + messenger: messenger as unknown as KeyringInternalSnapClientMessenger, + }); + + messenger.call.mockResolvedValue(accountsList); + const accounts = await client.withSnapId(snapId).getAccounts(); + expect(messenger.call).toHaveBeenCalledWith( + 'SnapController:handleRequest', + request, + ); + expect(accounts).toStrictEqual(accountsList); + }); + + it('calls the default snapId value ("undefined")', async () => { + const client = new KeyringInternalSnapClientV2({ + messenger: messenger as unknown as KeyringInternalSnapClientMessenger, + }); + + messenger.call.mockResolvedValue(accountsList); + await client.getAccounts(); + expect(messenger.call).toHaveBeenCalledWith( + 'SnapController:handleRequest', + { + ...request, + snapId: 'undefined', + }, + ); + }); + }); +}); diff --git a/packages/keyring-internal-snap-client/src/v2/KeyringInternalSnapClientV2.ts b/packages/keyring-internal-snap-client/src/v2/KeyringInternalSnapClientV2.ts new file mode 100644 index 00000000..43f0f029 --- /dev/null +++ b/packages/keyring-internal-snap-client/src/v2/KeyringInternalSnapClientV2.ts @@ -0,0 +1,59 @@ +import { KeyringClientV2 } from '@metamask/keyring-snap-client'; +import type { SnapId } from '@metamask/snaps-sdk'; +import type { HandlerType } from '@metamask/snaps-utils'; + +import type { KeyringInternalSnapClientMessenger } from '../KeyringInternalSnapClient'; +import { SnapControllerMessengerSender } from '../KeyringInternalSnapClient'; + +/** + * A `KeyringClient` that allows the communication with a Snap through a + * `Messenger`. + */ +export class KeyringInternalSnapClientV2 extends KeyringClientV2 { + readonly #messenger: KeyringInternalSnapClientMessenger; + + /** + * Create a new instance of `KeyringInternalSnapClientV2`. + * + * The `handlerType` argument has a hard-coded default `string` value instead + * of a `HandlerType` value to prevent the `@metamask/snaps-utils` module + * from being required at runtime. + * + * @param args - Constructor arguments. + * @param args.messenger - The `KeyringInternalSnapClientMessenger` instance to use. + * @param args.snapId - The ID of the Snap to use (default: `'undefined'`). + * @param args.origin - The sender's origin (default: `'metamask'`). + * @param args.handler - The handler type (default: `'onKeyringRequest'`). + */ + constructor({ + messenger, + snapId = 'undefined' as SnapId, + origin = 'metamask', + handler = 'onKeyringRequest' as HandlerType, + }: { + messenger: KeyringInternalSnapClientMessenger; + snapId?: SnapId; + origin?: string; + handler?: HandlerType; + }) { + super( + new SnapControllerMessengerSender(messenger, snapId, origin, handler), + ); + this.#messenger = messenger; + } + + /** + * Create a new instance of `KeyringInternalSnapClientV2` with the specified + * `snapId`. + * + * @param snapId - The ID of the Snap to use in the new instance. + * @returns A new instance of `KeyringInternalSnapClientV2` with the + * specified Snap ID. + */ + withSnapId(snapId: SnapId): KeyringInternalSnapClientV2 { + return new KeyringInternalSnapClientV2({ + messenger: this.#messenger, + snapId, + }); + } +} diff --git a/packages/keyring-internal-snap-client/src/v2/index.ts b/packages/keyring-internal-snap-client/src/v2/index.ts new file mode 100644 index 00000000..15970e56 --- /dev/null +++ b/packages/keyring-internal-snap-client/src/v2/index.ts @@ -0,0 +1 @@ +export * from './KeyringInternalSnapClientV2'; diff --git a/packages/keyring-snap-client/src/index.ts b/packages/keyring-snap-client/src/index.ts index 3e845b70..289955e5 100644 --- a/packages/keyring-snap-client/src/index.ts +++ b/packages/keyring-snap-client/src/index.ts @@ -1,3 +1,4 @@ export * from './KeyringClient'; export * from './KeyringSnapRpcClient'; export * from './KeyringPublicClient'; +export * from './v2'; diff --git a/packages/keyring-snap-client/src/v2/KeyringClientV2.test.ts b/packages/keyring-snap-client/src/v2/KeyringClientV2.test.ts new file mode 100644 index 00000000..50b38f19 --- /dev/null +++ b/packages/keyring-snap-client/src/v2/KeyringClientV2.test.ts @@ -0,0 +1,193 @@ +import { + KeyringRpcV2Method, + PrivateKeyEncoding, + type KeyringAccount, + type KeyringRequest, +} from '@metamask/keyring-api'; +import type { Json } from '@metamask/utils'; + +import { KeyringClientV2 } from './KeyringClientV2'; + +describe('KeyringClientV2', () => { + const mockSender = { + send: jest.fn(), + }; + + beforeEach(() => { + mockSender.send.mockClear(); + }); + + describe('KeyringClientV2', () => { + const client = new KeyringClientV2(mockSender); + + describe('getAccounts', () => { + it('sends a request to get accounts and return the response', async () => { + const expectedResponse: KeyringAccount[] = [ + { + id: '49116980-0712-4fa5-b045-e4294f1d440e', + address: '0xE9A74AACd7df8112911ca93260fC5a046f8a64Ae', + options: {}, + methods: [], + scopes: ['eip155:0'], + type: 'eip155:eoa', + }, + ]; + + mockSender.send.mockResolvedValue(expectedResponse); + const accounts = await client.getAccounts(); + expect(mockSender.send).toHaveBeenCalledWith({ + jsonrpc: '2.0', + id: expect.any(String), + method: `${KeyringRpcV2Method.GetAccounts}`, + }); + expect(accounts).toStrictEqual(expectedResponse); + }); + }); + + describe('getAccount', () => { + it('sends a request to get an account by ID and return the response', async () => { + const id = '49116980-0712-4fa5-b045-e4294f1d440e'; + const expectedResponse: KeyringAccount = { + id: '49116980-0712-4fa5-b045-e4294f1d440e', + address: '0xE9A74AACd7df8112911ca93260fC5a046f8a64Ae', + options: {}, + methods: [], + scopes: ['eip155:0'], + type: 'eip155:eoa', + }; + + mockSender.send.mockResolvedValue(expectedResponse); + const account = await client.getAccount(id); + expect(mockSender.send).toHaveBeenCalledWith({ + jsonrpc: '2.0', + id: expect.any(String), + method: `${KeyringRpcV2Method.GetAccount}`, + params: { id }, + }); + expect(account).toStrictEqual(expectedResponse); + }); + }); + + describe('createAccounts', () => { + it('sends a request to create an account and return the response', async () => { + const expectedResponse: KeyringAccount[] = [ + { + id: '49116980-0712-4fa5-b045-e4294f1d440e', + address: '0xE9A74AACd7df8112911ca93260fC5a046f8a64Ae', + options: {}, + methods: [], + scopes: ['eip155:0'], + type: 'eip155:eoa', + }, + ]; + + const createAccountOptions = { + type: 'bip44:derive-index', + entropySource: 'mock-entropy-source', + groupIndex: 0, + } as const; + + mockSender.send.mockResolvedValue(expectedResponse); + const account = await client.createAccounts(createAccountOptions); + expect(mockSender.send).toHaveBeenCalledWith({ + jsonrpc: '2.0', + id: expect.any(String), + method: `${KeyringRpcV2Method.CreateAccounts}`, + params: createAccountOptions, + }); + expect(account).toStrictEqual(expectedResponse); + }); + }); + + describe('deleteAccount', () => { + it('sends a request to delete an account', async () => { + const id = '49116980-0712-4fa5-b045-e4294f1d440e'; + + mockSender.send.mockResolvedValue(null); + const response = await client.deleteAccount(id); + expect(mockSender.send).toHaveBeenCalledWith({ + jsonrpc: '2.0', + id: expect.any(String), + method: `${KeyringRpcV2Method.DeleteAccount}`, + params: { id }, + }); + expect(response).toBeUndefined(); + }); + }); + + describe('exportAccount', () => { + it('sends a request to export an account', async () => { + const id = '49116980-0712-4fa5-b045-e4294f1d440e'; + const expectedResponse = { + type: 'private-key', + privateKey: '0x000000000', + encoding: 'hexadecimal', + }; + + mockSender.send.mockResolvedValue(expectedResponse); + const response = await client.exportAccount(id); + expect(mockSender.send).toHaveBeenCalledWith({ + jsonrpc: '2.0', + id: expect.any(String), + method: `${KeyringRpcV2Method.ExportAccount}`, + params: { id }, + }); + expect(response).toStrictEqual(expectedResponse); + }); + + it('sends a request to export an account with options', async () => { + const id = '49116980-0712-4fa5-b045-e4294f1d440e'; + const expectedResponse = { + type: 'private-key', + privateKey: '0x000000000', + encoding: 'hexadecimal', + }; + const options = { + type: 'private-key' as const, + encoding: PrivateKeyEncoding.Hexadecimal, + }; + + mockSender.send.mockResolvedValue(expectedResponse); + const response = await client.exportAccount(id, options); + expect(mockSender.send).toHaveBeenCalledWith({ + jsonrpc: '2.0', + id: expect.any(String), + method: `${KeyringRpcV2Method.ExportAccount}`, + params: { + id, + options, + }, + }); + expect(response).toStrictEqual(expectedResponse); + }); + }); + + describe('submitRequest', () => { + it('sends a request to submit a request', async () => { + const request: KeyringRequest = { + id: '71621d8d-62a4-4bf4-97cc-fb8f243679b0', + scope: 'eip155:1', + origin: 'test', + account: '46b5ccd3-4786-427c-89d2-cef626dffe9b', + request: { + method: 'personal_sign', + params: ['0xe9a74aacd7df8112911ca93260fc5a046f8a64ae', '0x0'], + }, + }; + const expectedResponse: Json = { + result: 'success', + }; + + mockSender.send.mockResolvedValue(expectedResponse); + const response = await client.submitRequest(request); + expect(mockSender.send).toHaveBeenCalledWith({ + jsonrpc: '2.0', + id: expect.any(String), + method: `${KeyringRpcV2Method.SubmitRequest}`, + params: request, + }); + expect(response).toStrictEqual(expectedResponse); + }); + }); + }); +}); diff --git a/packages/keyring-snap-client/src/v2/KeyringClientV2.ts b/packages/keyring-snap-client/src/v2/KeyringClientV2.ts new file mode 100644 index 00000000..dc9bd33d --- /dev/null +++ b/packages/keyring-snap-client/src/v2/KeyringClientV2.ts @@ -0,0 +1,128 @@ +import { + CreateAccountsV2ResponseStruct, + DeleteAccountV2ResponseStruct, + GetAccountV2ResponseStruct, + GetAccountsV2ResponseStruct, + SubmitRequestV2ResponseStruct, + KeyringRpcV2Method, + ExportAccountV2ResponseStruct, +} from '@metamask/keyring-api'; +import type { + CreateAccountOptions, + ExportAccountOptions, + ExportedAccount, + KeyringAccount, + KeyringRequest, + KeyringRpcV2, + KeyringRpcV2Request, +} from '@metamask/keyring-api'; +import type { AccountId } from '@metamask/keyring-utils'; +import { strictMask } from '@metamask/keyring-utils'; +import { assert } from '@metamask/superstruct'; +import type { Json } from '@metamask/utils'; +import { v4 as uuid } from 'uuid'; + +import type { Sender } from '../KeyringClient'; + +export class KeyringClientV2 implements KeyringRpcV2 { + readonly #sender: Sender; + + /** + * Create a new instance of `KeyringClient`. + * + * @param sender - The `Sender` instance to use to send requests to the snap. + */ + constructor(sender: Sender) { + this.#sender = sender; + } + + /** + * Send a request to the Snap and return the response. + * + * @param request - A partial JSON-RPC request (method and params). + * @returns A promise that resolves to the response to the request. + */ + protected async send( + request: KeyringRpcV2Request, + ): Promise { + return this.#sender.send({ + ...request, + }); + } + + async getAccounts(): Promise { + return strictMask( + await this.send({ + jsonrpc: '2.0', + id: uuid(), + method: KeyringRpcV2Method.GetAccounts, + }), + GetAccountsV2ResponseStruct, + ); + } + + async getAccount(id: string): Promise { + return strictMask( + await this.send({ + jsonrpc: '2.0', + id: uuid(), + method: KeyringRpcV2Method.GetAccount, + params: { id }, + }), + GetAccountV2ResponseStruct, + ); + } + + async createAccounts( + params: CreateAccountOptions, + ): Promise { + return strictMask( + await this.send({ + jsonrpc: '2.0', + id: uuid(), + method: KeyringRpcV2Method.CreateAccounts, + params, + }), + CreateAccountsV2ResponseStruct, + ); + } + + async exportAccount( + id: AccountId, + options?: ExportAccountOptions, + ): Promise { + return strictMask( + await this.send({ + jsonrpc: '2.0', + id: uuid(), + method: KeyringRpcV2Method.ExportAccount, + params: { id, ...(options ? { options } : {}) }, + }), + ExportAccountV2ResponseStruct, + ); + } + + async deleteAccount(id: AccountId): Promise { + assert( + await this.send({ + jsonrpc: '2.0', + id: uuid(), + method: KeyringRpcV2Method.DeleteAccount, + params: { id }, + }), + DeleteAccountV2ResponseStruct, + ); + } + + async submitRequest(request: KeyringRequest): Promise { + return strictMask( + await this.send({ + jsonrpc: '2.0', + id: uuid(), + method: KeyringRpcV2Method.SubmitRequest, + params: request, + }), + SubmitRequestV2ResponseStruct, + ); + } +} diff --git a/packages/keyring-snap-client/src/v2/index.ts b/packages/keyring-snap-client/src/v2/index.ts new file mode 100644 index 00000000..d272e687 --- /dev/null +++ b/packages/keyring-snap-client/src/v2/index.ts @@ -0,0 +1 @@ +export * from './KeyringClientV2'; diff --git a/packages/keyring-snap-sdk/src/index.ts b/packages/keyring-snap-sdk/src/index.ts index b4ca8ca6..b4a56df5 100644 --- a/packages/keyring-snap-sdk/src/index.ts +++ b/packages/keyring-snap-sdk/src/index.ts @@ -2,3 +2,4 @@ export * from './rpc-handler'; export * from './snap-utils'; export * from './time'; export * from './methods'; +export * from './v2'; diff --git a/packages/keyring-snap-sdk/src/v2/index.ts b/packages/keyring-snap-sdk/src/v2/index.ts new file mode 100644 index 00000000..7b53817f --- /dev/null +++ b/packages/keyring-snap-sdk/src/v2/index.ts @@ -0,0 +1 @@ +export * from './rpc-handler'; diff --git a/packages/keyring-snap-sdk/src/v2/rpc-handler.test.ts b/packages/keyring-snap-sdk/src/v2/rpc-handler.test.ts new file mode 100644 index 00000000..a2a64096 --- /dev/null +++ b/packages/keyring-snap-sdk/src/v2/rpc-handler.test.ts @@ -0,0 +1,264 @@ +import { KeyringRpcV2Method, PrivateKeyEncoding } from '@metamask/keyring-api'; +import type { + KeyringType, + CreateAccountsV2Request, + GetAccountV2Request, + GetAccountsV2Request, + DeleteAccountV2Request, + KeyringV2, + ExportAccountV2Request, + SubmitRequestV2Request, +} from '@metamask/keyring-api'; +import type { JsonRpcRequest } from '@metamask/keyring-utils'; + +import { handleKeyringRequestV2 } from './rpc-handler'; + +describe('handleKeyringRequestV2', () => { + const keyring = { + getAccounts: jest.fn(), + getAccount: jest.fn(), + createAccounts: jest.fn(), + deleteAccount: jest.fn(), + exportAccount: jest.fn(), + submitRequest: jest.fn(), + // Not required by this test. + type: 'Mocked Keyring' as KeyringType, + capabilities: { + scopes: [], + }, + serialize: jest.fn(), + deserialize: jest.fn(), + }; + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('fails to execute an mal-formatted JSON-RPC request', async () => { + const request = { + jsonrpc: '2.0', + id: '7c507ff0-365f-4de0-8cd5-eb83c30ebda4', + // Missing method name. + }; + + await expect( + handleKeyringRequestV2(keyring, request as unknown as JsonRpcRequest), + ).rejects.toThrow( + 'At path: method -- Expected a string, but received: undefined', + ); + }); + + it('calls `keyring_v2_getAccounts`', async () => { + const request: GetAccountsV2Request = { + jsonrpc: '2.0', + id: '7c507ff0-365f-4de0-8cd5-eb83c30ebda4', + method: `${KeyringRpcV2Method.GetAccounts}`, + }; + + const mockedResult = 'GetAccounts result'; + keyring.getAccounts.mockResolvedValue(mockedResult); + const result = await handleKeyringRequestV2(keyring, request); + + expect(keyring.getAccounts).toHaveBeenCalled(); + expect(result).toBe(mockedResult); + }); + + it('calls `keyring_v2_getAccount`', async () => { + const request: GetAccountV2Request = { + jsonrpc: '2.0', + id: '7c507ff0-365f-4de0-8cd5-eb83c30ebda4', + method: `${KeyringRpcV2Method.GetAccount}`, + params: { id: '4f983fa2-4f53-4c63-a7c2-f9a5ed750041' }, + }; + + const mockedResult = 'GetAccount result'; + keyring.getAccount.mockResolvedValue(mockedResult); + const result = await handleKeyringRequestV2(keyring, request); + + expect(keyring.getAccount).toHaveBeenCalledWith(request.params.id); + expect(result).toBe(mockedResult); + }); + + it('fails to call `keyring_v2_getAccount` without providing an account ID', async () => { + const request: GetAccountV2Request = { + jsonrpc: '2.0', + id: '7c507ff0-365f-4de0-8cd5-eb83c30ebda4', + method: `${KeyringRpcV2Method.GetAccount}`, + // @ts-expect-error - Testing error case. + params: {}, // Missing account ID. + }; + + await expect(handleKeyringRequestV2(keyring, request)).rejects.toThrow( + 'At path: params.id -- Expected a value of type `UuidV4`, but received: `undefined`', + ); + }); + + it('fails to call `keyring_v2_getAccount` when the `params` is not provided', async () => { + // @ts-expect-error - Testing error case. + const request: GetAccountV2Request = { + jsonrpc: '2.0', + id: '7c507ff0-365f-4de0-8cd5-eb83c30ebda4', + method: `${KeyringRpcV2Method.GetAccount}`, + }; + + await expect(handleKeyringRequestV2(keyring, request)).rejects.toThrow( + 'At path: params -- Expected an object, but received: undefined', + ); + }); + + it('calls `keyring_v2_createAccounts`', async () => { + const request: CreateAccountsV2Request = { + jsonrpc: '2.0', + id: '7c507ff0-365f-4de0-8cd5-eb83c30ebda4', + method: `${KeyringRpcV2Method.CreateAccounts}`, + params: { + type: 'bip44:derive-index', + groupIndex: 0, + entropySource: 'mock-entropy-source', + }, + }; + + const mockedResult = 'CreateAccounts result'; + keyring.createAccounts.mockResolvedValue(mockedResult); + const result = await handleKeyringRequestV2(keyring, request); + + expect(keyring.createAccounts).toHaveBeenCalledWith(request.params); + expect(result).toBe(mockedResult); + }); + + it('calls `keyring_v2_deleteAccount`', async () => { + const request: DeleteAccountV2Request = { + jsonrpc: '2.0', + id: '7c507ff0-365f-4de0-8cd5-eb83c30ebda4', + method: `${KeyringRpcV2Method.DeleteAccount}`, + params: { id: '4f983fa2-4f53-4c63-a7c2-f9a5ed750041' }, + }; + + keyring.deleteAccount.mockResolvedValue(undefined); + await handleKeyringRequestV2(keyring, request); + + expect(keyring.deleteAccount).toHaveBeenCalledWith(request.params.id); + }); + + it('calls `keyring_v2_exportAccount` (without options)', async () => { + const request: ExportAccountV2Request = { + jsonrpc: '2.0', + id: '7c507ff0-365f-4de0-8cd5-eb83c30ebda4', + method: `${KeyringRpcV2Method.ExportAccount}`, + params: { id: '4f983fa2-4f53-4c63-a7c2-f9a5ed750041' }, + }; + + const mockedResult = { + privateKey: '0x0123', + }; + keyring.exportAccount.mockResolvedValue(mockedResult); + const result = await handleKeyringRequestV2(keyring, request); + + expect(keyring.exportAccount).toHaveBeenCalledWith( + request.params.id, + undefined, + ); + expect(result).toStrictEqual(mockedResult); + }); + + it('calls `keyring_v2_exportAccount` (with options)', async () => { + const request: ExportAccountV2Request = { + jsonrpc: '2.0', + id: '7c507ff0-365f-4de0-8cd5-eb83c30ebda4', + method: `${KeyringRpcV2Method.ExportAccount}`, + params: { + id: '4f983fa2-4f53-4c63-a7c2-f9a5ed750041', + options: { + type: 'private-key', + encoding: PrivateKeyEncoding.Hexadecimal, + }, + }, + }; + + const mockedResult = { + privateKey: '0x0123', + }; + keyring.exportAccount.mockResolvedValue(mockedResult); + const result = await handleKeyringRequestV2(keyring, request); + + expect(keyring.exportAccount).toHaveBeenCalledWith( + request.params.id, + request.params.options, + ); + expect(result).toStrictEqual(mockedResult); + }); + + it('throws an error if `keyring_v2_exportAccount` is not implemented', async () => { + const request: ExportAccountV2Request = { + jsonrpc: '2.0', + id: '7c507ff0-365f-4de0-8cd5-eb83c30ebda4', + method: `${KeyringRpcV2Method.ExportAccount}`, + params: { id: '4f983fa2-4f53-4c63-a7c2-f9a5ed750041' }, + }; + + const partialKeyring: KeyringV2 = { + ...keyring, + }; + delete partialKeyring.exportAccount; + + await expect( + handleKeyringRequestV2(partialKeyring, request), + ).rejects.toThrow( + `Method not supported: ${KeyringRpcV2Method.ExportAccount}`, + ); + }); + + it('calls `keyring_v2_submitRequest`', async () => { + const dappRequest = { + id: 'c555de37-cf4b-4ff2-8273-39db7fb58f1c', + scope: 'eip155:1', + account: '4abdd17e-8b0f-4d06-a017-947a64823b3d', + origin: 'metamask', + request: { + method: 'eth_method', + params: [1, 2, 3], + }, + }; + + const request: SubmitRequestV2Request = { + jsonrpc: '2.0', + id: '7c507ff0-365f-4de0-8cd5-eb83c30ebda4', + method: `${KeyringRpcV2Method.SubmitRequest}`, + params: dappRequest, + }; + + const mockedResult = 'SubmitRequest result'; + keyring.submitRequest.mockResolvedValue(mockedResult); + const result = await handleKeyringRequestV2(keyring, request); + + expect(keyring.submitRequest).toHaveBeenCalledWith(dappRequest); + expect(result).toBe(mockedResult); + }); + + it('throws an error if an unknown method is called', async () => { + const request: JsonRpcRequest = { + jsonrpc: '2.0', + id: '7c507ff0-365f-4de0-8cd5-eb83c30ebda4', + method: 'unknown_method', + }; + + await expect(handleKeyringRequestV2(keyring, request)).rejects.toThrow( + 'Method not supported: unknown_method', + ); + }); + + it('throws an "unknown error" if the error message is not a string', async () => { + const request: JsonRpcRequest = { + jsonrpc: '2.0', + id: '80c25a6b-4a76-44f4-88c5-7b3b76f72a74', + method: `${KeyringRpcV2Method.GetAccounts}`, + }; + + const error = new Error(); + error.message = 1 as unknown as string; + keyring.getAccounts.mockRejectedValue(error); + await expect(handleKeyringRequestV2(keyring, request)).rejects.toThrow( + 'An unknown error occurred while handling the keyring (v2) request', + ); + }); +}); diff --git a/packages/keyring-snap-sdk/src/v2/rpc-handler.ts b/packages/keyring-snap-sdk/src/v2/rpc-handler.ts new file mode 100644 index 00000000..e4d9a8d4 --- /dev/null +++ b/packages/keyring-snap-sdk/src/v2/rpc-handler.ts @@ -0,0 +1,107 @@ +import type { KeyringV2 } from '@metamask/keyring-api'; +import { + KeyringRpcV2Method, + GetAccountsV2RequestStruct, + GetAccountV2RequestStruct, + CreateAccountsV2RequestStruct, + DeleteAccountV2RequestStruct, + ExportAccountV2RequestStruct, + SubmitRequestV2RequestStruct, +} from '@metamask/keyring-api'; +import type { JsonRpcRequest } from '@metamask/keyring-utils'; +import { JsonRpcRequestStruct } from '@metamask/keyring-utils'; +import { assert } from '@metamask/superstruct'; +import type { Json } from '@metamask/utils'; + +import { MethodNotSupportedError } from '../rpc-handler'; + +/** + * Inner function that dispatches JSON-RPC request to the associated Keyring + * methods. + * + * @param keyring - Keyring instance. + * @param request - Keyring JSON-RPC request. + * @returns A promise that resolves to the keyring response. + */ +async function dispatchKeyringRequestV2( + keyring: KeyringV2, + request: JsonRpcRequest, +): Promise { + // We first have to make sure that the request is a valid JSON-RPC request so + // we can check its method name. + assert(request, JsonRpcRequestStruct); + + switch (request.method) { + case `${KeyringRpcV2Method.GetAccounts}`: { + assert(request, GetAccountsV2RequestStruct); + return keyring.getAccounts(); + } + + case `${KeyringRpcV2Method.GetAccount}`: { + assert(request, GetAccountV2RequestStruct); + return keyring.getAccount(request.params.id); + } + + case `${KeyringRpcV2Method.CreateAccounts}`: { + assert(request, CreateAccountsV2RequestStruct); + return keyring.createAccounts(request.params); + } + + case `${KeyringRpcV2Method.DeleteAccount}`: { + assert(request, DeleteAccountV2RequestStruct); + return keyring.deleteAccount(request.params.id); + } + + case `${KeyringRpcV2Method.ExportAccount}`: { + if (keyring.exportAccount === undefined) { + throw new MethodNotSupportedError(request.method); + } + assert(request, ExportAccountV2RequestStruct); + return keyring.exportAccount(request.params.id, request.params.options); + } + + case `${KeyringRpcV2Method.SubmitRequest}`: { + assert(request, SubmitRequestV2RequestStruct); + return keyring.submitRequest(request.params); + } + + default: { + throw new MethodNotSupportedError(request.method); + } + } +} + +/** + * Handles a keyring (v2) JSON-RPC request. + * + * This function is meant to be used as a handler for Keyring (v2) JSON-RPC requests + * in an Accounts Snap. + * + * @param keyring - Keyring instance. + * @param request - Keyring JSON-RPC request. + * @returns A promise that resolves to the keyring response. + * @example + * ```ts + * export const onKeyringRequest: OnKeyringRequestHandler = async ({ + * origin, + * request, + * }) => { + * return await handleKeyringRequestV2(keyring, request); + * }; + * ``` + */ +export async function handleKeyringRequestV2( + keyring: KeyringV2, + request: JsonRpcRequest, +): Promise { + try { + return await dispatchKeyringRequestV2(keyring, request); + } catch (error) { + const message = + error instanceof Error && typeof error.message === 'string' + ? error.message + : 'An unknown error occurred while handling the keyring (v2) request'; + + throw new Error(message); + } +}