diff --git a/packages/stellar-wallet-snap/snap.manifest.json b/packages/stellar-wallet-snap/snap.manifest.json index a96e17e27..8a854cc6d 100644 --- a/packages/stellar-wallet-snap/snap.manifest.json +++ b/packages/stellar-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/internal-snaps.git" }, "source": { - "shasum": "E8ptwYRcKs5dUP6eKBAJu4cRzIYNfGVI1kDp1znZkYI=", + "shasum": "MGLWLhlYBn+se/MUt9Lq70AkSl4F4oZaVAVXRgqtoLQ=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/stellar-wallet-snap/src/context.ts b/packages/stellar-wallet-snap/src/context.ts index d27f6a734..9f61335cb 100644 --- a/packages/stellar-wallet-snap/src/context.ts +++ b/packages/stellar-wallet-snap/src/context.ts @@ -320,7 +320,6 @@ const clientRequestMethodHandlers: Record< }; const clientRequestHandler = new ClientRequestHandler({ - logger, handlers: clientRequestMethodHandlers, }); diff --git a/packages/stellar-wallet-snap/src/handlers/clientRequest/clientRequest.test.ts b/packages/stellar-wallet-snap/src/handlers/clientRequest/clientRequest.test.ts new file mode 100644 index 000000000..2e865c33d --- /dev/null +++ b/packages/stellar-wallet-snap/src/handlers/clientRequest/clientRequest.test.ts @@ -0,0 +1,47 @@ +import { MethodNotFoundError } from '@metamask/snaps-sdk'; + +import { ClientRequestMethod } from './api'; +import { ClientRequestHandler } from './clientRequest'; + +describe('ClientRequestHandler', () => { + const mockHandle = jest.fn(); + + function setup(): ClientRequestHandler { + mockHandle.mockReset(); + return new ClientRequestHandler({ + handlers: { + [ClientRequestMethod.ComputeFee]: { handle: mockHandle }, + } as never, + }); + } + + const request = { + jsonrpc: '2.0', + id: 1, + method: ClientRequestMethod.ComputeFee, + } as const; + + it('routes the request to the handler for its method', async () => { + const handler = setup(); + mockHandle.mockResolvedValue({ fee: '100' }); + + expect(await handler.handle(request)).toStrictEqual({ fee: '100' }); + expect(mockHandle).toHaveBeenCalledWith(request); + }); + + it('returns null when the method handler returns nothing', async () => { + const handler = setup(); + mockHandle.mockResolvedValue(undefined); + + expect(await handler.handle(request)).toBeNull(); + }); + + it('throws MethodNotFoundError for an unknown method', async () => { + const handler = setup(); + + await expect( + handler.handle({ ...request, method: 'unknownMethod' }), + ).rejects.toThrow(MethodNotFoundError); + expect(mockHandle).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/stellar-wallet-snap/src/handlers/clientRequest/clientRequest.ts b/packages/stellar-wallet-snap/src/handlers/clientRequest/clientRequest.ts index fb0be93e3..aa32a3125 100644 --- a/packages/stellar-wallet-snap/src/handlers/clientRequest/clientRequest.ts +++ b/packages/stellar-wallet-snap/src/handlers/clientRequest/clientRequest.ts @@ -1,26 +1,19 @@ -import type { Logger } from '@metamask/snap-networks-utils'; import { MethodNotFoundError } from '@metamask/snaps-sdk'; import type { Json, JsonRpcRequest } from '@metamask/utils'; import { ensureError } from '@metamask/utils'; -import { withCatchAndThrowSnapError } from '../../utils'; import type { ClientRequestMethod } from './api'; import { ClientRequestMethodStruct } from './api'; import type { IClientRequestHandler } from './base'; export class ClientRequestHandler { - readonly #logger: Logger; - readonly #handlers: Record; constructor({ - logger, handlers, }: { - logger: Logger; handlers: Record; }) { - this.#logger = logger.withPrefix('[👋 ClientRequestHandler]'); this.#handlers = handlers; } @@ -35,12 +28,7 @@ export class ClientRequestHandler { * @throws {InvalidParamsError} If the params are invalid. */ async handle(request: JsonRpcRequest): Promise { - const result = - (await withCatchAndThrowSnapError(async () => { - return this.#handleClientRequest(request); - }, this.#logger.error.bind(this.#logger))) ?? null; - - return result; + return (await this.#handleClientRequest(request)) ?? null; } /** diff --git a/packages/stellar-wallet-snap/src/handlers/cronjob/cronjob.ts b/packages/stellar-wallet-snap/src/handlers/cronjob/cronjob.ts index ec3be2b43..82ae14b45 100644 --- a/packages/stellar-wallet-snap/src/handlers/cronjob/cronjob.ts +++ b/packages/stellar-wallet-snap/src/handlers/cronjob/cronjob.ts @@ -1,6 +1,5 @@ import type { JsonRpcRequest } from '@metamask/snaps-sdk'; -import { withCatchAndThrowSnapError } from '../../utils'; import { getClientStatus } from '../../utils/snap'; import type { BackgroundEventMethod, ICronjobRequestHandler } from './api'; import { BackgroundEventMethodStruct } from './api'; @@ -18,16 +17,14 @@ export class CronjobHandler { } async handle(request: JsonRpcRequest): Promise { - await withCatchAndThrowSnapError(async () => { - const { active, locked } = await getClientStatus(); + const { active, locked } = await getClientStatus(); - // if the client is not active or locked, we dont execute the cronjob - if (!active || locked) { - return; - } + // if the client is not active or locked, we dont execute the cronjob + if (!active || locked) { + return; + } - await this.#handleRequest(request); - }); + await this.#handleRequest(request); } async #handleRequest(request: JsonRpcRequest): Promise { diff --git a/packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts b/packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts index d319c1e98..12c835268 100644 --- a/packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts +++ b/packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts @@ -62,7 +62,6 @@ import { isSlip44Id, rethrowIfInstanceElseThrow, validateRequest, - withCatchAndThrowSnapError, } from '../../utils'; import { getSupportedScopes } from '../../utils/scopes'; import { SyncAccountsHandler } from '../cronjob/syncAccounts'; @@ -81,6 +80,8 @@ import { import type { IKeyringRequestHandler } from './base'; import { ExportAccountException } from './exceptions'; +export const KEYRING_HANDLER_LOGGER_PREFIX = '[🔑 KeyringHandler]'; + export class KeyringHandler implements KeyringSnapRpc { readonly #logger: Logger; @@ -109,7 +110,7 @@ export class KeyringHandler implements KeyringSnapRpc { walletService: WalletService; handlers: Record; }) { - this.#logger = logger.withPrefix('[🔑 KeyringHandler]'); + this.#logger = logger.withPrefix(KEYRING_HANDLER_LOGGER_PREFIX); this.#accountService = accountService; this.#onChainAccountService = onChainAccountService; this.#transactionService = transactionService; @@ -118,22 +119,17 @@ export class KeyringHandler implements KeyringSnapRpc { } async handle(origin: string, request: JsonRpcRequest): Promise { - const result = - (await withCatchAndThrowSnapError(async () => { - this.#logger.debug('Handle keyring request', { - origin, - method: request.method, - }); - validateOrigin(origin, request.method, originPermissions); - const keyringRequestResult = await handleKeyringRequest(this, request); - this.#logger.debug('Keyring request handled', { - origin, - method: request.method, - }); - return keyringRequestResult; - }, this.#logger.error.bind(this.#logger))) ?? null; - - return result; + this.#logger.debug('Handle keyring request', { + origin, + method: request.method, + }); + validateOrigin(origin, request.method, originPermissions); + const result = await handleKeyringRequest(this, request); + this.#logger.debug('Keyring request handled', { + origin, + method: request.method, + }); + return result ?? null; } async getAccount(accountId: GetAccountRequest): Promise { diff --git a/packages/stellar-wallet-snap/src/handlers/user-input/userInput.test.ts b/packages/stellar-wallet-snap/src/handlers/user-input/userInput.test.ts new file mode 100644 index 000000000..5506ff0cd --- /dev/null +++ b/packages/stellar-wallet-snap/src/handlers/user-input/userInput.test.ts @@ -0,0 +1,104 @@ +import { logger } from '../../utils/logger'; +import type { UserInputUiEventHandler } from './api'; +import { UserInputHandler } from './userInput'; + +const mockEventHandler = jest.fn(); + +type MockEventsModule = { + createEventHandlers: () => Record; +}; + +/** + * Builds a mocked `events` module exposing the given UI event handlers. + * + * @param handlers - The UI event handlers keyed by event name. + * @returns The mocked module. + */ +function mockEventsModule( + handlers: Record = {}, +): MockEventsModule { + return { createEventHandlers: (): typeof handlers => handlers }; +} + +jest.mock('../../utils/logger'); + +jest.mock( + '../../ui/confirmation/views/ConfirmSignMessage/events', + (): MockEventsModule => + mockEventsModule({ + testEvent: async (...args): Promise => mockEventHandler(...args), + }), +); +jest.mock( + '../../ui/confirmation/views/ConfirmSendTransaction/events', + (): MockEventsModule => mockEventsModule(), +); +jest.mock( + '../../ui/confirmation/views/ConfirmSignAuthEntry/events', + (): MockEventsModule => mockEventsModule(), +); +jest.mock( + '../../ui/confirmation/views/ConfirmSignChangeTrustOptIn/events', + (): MockEventsModule => mockEventsModule(), +); +jest.mock( + '../../ui/confirmation/views/ConfirmSignChangeTrustOptOut/events', + (): MockEventsModule => mockEventsModule(), +); +jest.mock( + '../../ui/confirmation/views/ConfirmSignTransaction/events', + (): MockEventsModule => mockEventsModule(), +); +jest.mock( + '../../ui/confirmation/views/MaliciousAcknowledgement/events', + (): MockEventsModule => mockEventsModule(), +); +jest.mock( + '../../ui/confirmation/views/MemoEdit/events', + (): MockEventsModule => mockEventsModule(), +); + +describe('UserInputHandler', () => { + const handler = new UserInputHandler({ logger }); + + beforeEach(() => { + mockEventHandler.mockReset().mockResolvedValue(undefined); + }); + + it('routes the event to the handler matching its name', async () => { + const params = { + id: 'interface-id', + event: { type: 'ButtonClickEvent', name: 'testEvent' }, + context: null, + } as never; + + await handler.handle(params); + + expect(mockEventHandler).toHaveBeenCalledWith(params); + }); + + it('propagates errors from the event handler', async () => { + mockEventHandler.mockRejectedValue(new Error('Event failed')); + + await expect( + handler.handle({ + id: 'interface-id', + event: { type: 'ButtonClickEvent', name: 'testEvent' }, + context: null, + } as never), + ).rejects.toThrow('Event failed'); + }); + + it.each([ + ['has no name', { type: 'ButtonClickEvent' }], + ['has no matching handler', { type: 'ButtonClickEvent', name: 'unknown' }], + ])('ignores an event that %s', async (_case, event) => { + await handler.handle({ + id: 'interface-id', + event, + context: null, + } as never); + + expect(mockEventHandler).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/stellar-wallet-snap/src/handlers/user-input/userInput.ts b/packages/stellar-wallet-snap/src/handlers/user-input/userInput.ts index 85fb07841..1844a528d 100644 --- a/packages/stellar-wallet-snap/src/handlers/user-input/userInput.ts +++ b/packages/stellar-wallet-snap/src/handlers/user-input/userInput.ts @@ -9,7 +9,6 @@ import { createEventHandlers as createSignMessageEvents } from '../../ui/confirm import { createEventHandlers as createSignTransactionEvents } from '../../ui/confirmation/views/ConfirmSignTransaction/events'; import { createEventHandlers as createMaliciousAcknowledgementEvents } from '../../ui/confirmation/views/MaliciousAcknowledgement/events'; import { createEventHandlers as createMemoEditEvents } from '../../ui/confirmation/views/MemoEdit/events'; -import { withCatchAndThrowSnapError } from '../../utils'; import type { UserInputUiEventHandler } from './api'; export class UserInputHandler { @@ -63,8 +62,6 @@ export class UserInputHandler { return; } - await withCatchAndThrowSnapError(async () => - handler({ id, event, context }), - ); + await handler({ id, event, context }); } } diff --git a/packages/stellar-wallet-snap/src/index.test.ts b/packages/stellar-wallet-snap/src/index.test.ts new file mode 100644 index 000000000..2b4adb590 --- /dev/null +++ b/packages/stellar-wallet-snap/src/index.test.ts @@ -0,0 +1,132 @@ +import { noopAssetHandlers } from '@metamask/snap-networks-utils'; +import { SnapError } from '@metamask/snaps-sdk'; + +import { + onAssetHistoricalPrice, + onAssetsConversion, + onAssetsLookup, + onAssetsMarketData, + onClientRequest, + onCronjob, + onKeyringRequest, + onUserInput, +} from '.'; +import { logger } from './utils/logger'; + +const mockKeyringHandle = jest.fn(); +const mockUserInputHandle = jest.fn(); +const mockClientRequestHandle = jest.fn(); +const mockCronjobHandle = jest.fn(); + +jest.mock('./utils/logger'); + +jest.mock('./context', () => ({ + keyringHandler: { + handle: async (...args: unknown[]): Promise => + mockKeyringHandle(...args), + }, + userInputHandler: { + handle: async (...args: unknown[]): Promise => + mockUserInputHandle(...args), + }, + clientRequestHandler: { + handle: async (...args: unknown[]): Promise => + mockClientRequestHandle(...args), + }, + cronjobHandler: { + handle: async (...args: unknown[]): Promise => + mockCronjobHandle(...args), + }, +})); + +describe('entrypoints', () => { + const snapRequest = jest.fn(); + const origin = 'https://example.com'; + const request = { jsonrpc: '2.0', id: 1, method: 'foo' } as const; + const userInputParams = { id: 'id', event: {}, context: null }; + + const entrypoints: [ + string, + () => Promise, + jest.Mock, + unknown[], + string[], + ][] = [ + [ + 'onKeyringRequest', + async (): Promise => + onKeyringRequest({ origin, request } as never), + mockKeyringHandle, + [origin, request], + ['[🔑 KeyringHandler]'], + ], + [ + 'onClientRequest', + async (): Promise => onClientRequest({ request } as never), + mockClientRequestHandle, + [request], + ['[👋 ClientRequestHandler]'], + ], + [ + 'onCronjob', + async (): Promise => onCronjob({ request } as never), + mockCronjobHandle, + [request], + [], + ], + [ + 'onUserInput', + async (): Promise => onUserInput(userInputParams as never), + mockUserInputHandle, + [userInputParams], + [], + ], + ]; + + beforeEach(() => { + jest.clearAllMocks(); + snapRequest.mockResolvedValue(undefined); + Object.assign(globalThis, { snap: { request: snapRequest } }); + }); + + it.each(entrypoints)( + 'delegates %s to its handler', + async (_name, callEntrypoint, handle, expectedArgs) => { + handle.mockResolvedValue('result'); + + expect(await callEntrypoint()).toBe('result'); + expect(handle).toHaveBeenCalledWith(...expectedArgs); + expect(snapRequest).not.toHaveBeenCalled(); + }, + ); + + it.each(entrypoints)( + 'normalizes, tracks, and logs %s errors', + async (_name, callEntrypoint, handle, _expectedArgs, prefixes) => { + handle.mockRejectedValue(new Error('Handler failed')); + + await expect(callEntrypoint()).rejects.toBeInstanceOf(SnapError); + + expect(snapRequest).toHaveBeenCalledWith({ + method: 'snap_trackError', + params: { + error: expect.objectContaining({ message: 'Handler failed' }), + }, + }); + expect(logger.error).toHaveBeenCalledWith( + ...prefixes, + { error: expect.any(SnapError) }, + expect.stringContaining('[SnapError]'), + ); + }, + ); + + it('exports the shared no-op asset handlers', () => { + expect({ + onAssetsLookup, + onAssetsConversion, + onAssetHistoricalPrice, + onAssetsMarketData, + }).toStrictEqual(noopAssetHandlers); + }); +}); diff --git a/packages/stellar-wallet-snap/src/index.ts b/packages/stellar-wallet-snap/src/index.ts index 336558e56..2e667179e 100644 --- a/packages/stellar-wallet-snap/src/index.ts +++ b/packages/stellar-wallet-snap/src/index.ts @@ -1,13 +1,7 @@ -import type { - OnUserInputHandler, - OnKeyringRequestHandler, - OnClientRequestHandler, - OnCronjobHandler, - OnAssetHistoricalPriceHandler, - OnAssetsConversionHandler, - OnAssetsLookupHandler, - OnAssetsMarketDataHandler, -} from '@metamask/snaps-sdk'; +import { + noopAssetHandlers, + wrapSnapHandlers, +} from '@metamask/snap-networks-utils'; import { keyringHandler, @@ -15,32 +9,32 @@ import { clientRequestHandler, cronjobHandler, } from './context'; - -export const onAssetsLookup: OnAssetsLookupHandler = async () => ({ - assets: {}, -}); - -export const onAssetsConversion: OnAssetsConversionHandler = async () => ({ - conversionRates: {}, -}); - -export const onAssetHistoricalPrice: OnAssetHistoricalPriceHandler = async () => - null; - -export const onAssetsMarketData: OnAssetsMarketDataHandler = async () => ({ - marketData: {}, -}); - -export const onKeyringRequest: OnKeyringRequestHandler = async ({ - origin, - request, -}) => keyringHandler.handle(origin, request); - -export const onUserInput: OnUserInputHandler = async (params) => - userInputHandler.handle(params); - -export const onClientRequest: OnClientRequestHandler = async ({ request }) => - clientRequestHandler.handle(request); - -export const onCronjob: OnCronjobHandler = async ({ request }) => - cronjobHandler.handle(request); +import { KEYRING_HANDLER_LOGGER_PREFIX } from './handlers'; +import { logger, withCatchAndThrowSnapError } from './utils'; + +const keyringLogger = logger.withPrefix(KEYRING_HANDLER_LOGGER_PREFIX); +const clientRequestLogger = logger.withPrefix('[👋 ClientRequestHandler]'); + +export const { onKeyringRequest, onUserInput, onClientRequest, onCronjob } = + wrapSnapHandlers( + withCatchAndThrowSnapError, + { + onKeyringRequest: async ({ origin, request }) => + keyringHandler.handle(origin, request), + onUserInput: async (params) => userInputHandler.handle(params), + onClientRequest: async ({ request }) => + clientRequestHandler.handle(request), + onCronjob: async ({ request }) => cronjobHandler.handle(request), + }, + { + onKeyringRequest: keyringLogger.error.bind(keyringLogger), + onClientRequest: clientRequestLogger.error.bind(clientRequestLogger), + }, + ); + +export const { + onAssetsLookup, + onAssetsConversion, + onAssetHistoricalPrice, + onAssetsMarketData, +} = noopAssetHandlers;