From a47ee48cfd097dd8263c5b456bf69f970666a2ce Mon Sep 17 00:00:00 2001 From: Andrew Taran Date: Mon, 31 Aug 2026 14:03:20 +0200 Subject: [PATCH 1/6] feat: move withCatchAndThrowSnapErrorHandler into shared lib --- .../src/errors/errors.test.ts | 196 ++++++++++++++++++ .../snap-networks-utils/src/errors/errors.ts | 78 +++++++ .../snap-networks-utils/src/errors/index.ts | 8 + .../src/errors/snapRpcError.ts | 69 ++++++ .../src/logger/__mocks__/Logger.ts | 26 +++ 5 files changed, 377 insertions(+) create mode 100644 packages/snap-networks-utils/src/errors/errors.test.ts create mode 100644 packages/snap-networks-utils/src/errors/errors.ts create mode 100644 packages/snap-networks-utils/src/errors/index.ts create mode 100644 packages/snap-networks-utils/src/errors/snapRpcError.ts create mode 100644 packages/snap-networks-utils/src/logger/__mocks__/Logger.ts diff --git a/packages/snap-networks-utils/src/errors/errors.test.ts b/packages/snap-networks-utils/src/errors/errors.test.ts new file mode 100644 index 00000000..d857b1af --- /dev/null +++ b/packages/snap-networks-utils/src/errors/errors.test.ts @@ -0,0 +1,196 @@ +import { + ChainDisconnectedError, + DisconnectedError, + InternalError, + InvalidInputError, + InvalidParamsError, + InvalidRequestError, + LimitExceededError, + MethodNotFoundError, + MethodNotSupportedError, + ParseError, + ResourceNotFoundError, + ResourceUnavailableError, + SnapError, + TransactionRejected, + UnauthorizedError, + UnsupportedMethodError, + UserRejectedRequestError, +} from '@metamask/snaps-sdk'; + +import { mockLogger } from '../logger/__mocks__/Logger'; +import { createWithCatchAndThrowSnapError, normalizeError } from './errors'; +import type { CreateWithCatchAndThrowSnapErrorOptions } from './errors'; +import { isSnapRpcError } from './snapRpcError'; + +const setupTest = () => { + jest.clearAllMocks(); + + const trackError = jest.fn(); + const withCatchAndThrowSnapError = createWithCatchAndThrowSnapError({ + logError: mockLogger['error'], + trackError, + }); + + return { + mockLogger, + trackError, + withCatchAndThrowSnapError, + createBoundWithCatchAndThrowSnapError: ( + options: Omit = { + trackError, + }, + ) => + createWithCatchAndThrowSnapError({ + ...options, + logError: mockLogger['error'], + }), + }; +}; + +describe('errors', () => { + describe('isSnapRpcError', () => { + it.each([ + new SnapError('Test error'), + new MethodNotFoundError(), + new UserRejectedRequestError(), + new MethodNotSupportedError(), + new ParseError(), + new ResourceNotFoundError(), + new ResourceUnavailableError(), + new TransactionRejected(), + new ChainDisconnectedError(), + new DisconnectedError(), + new UnauthorizedError(), + new UnsupportedMethodError(), + new InternalError(), + new InvalidInputError(), + new InvalidParamsError(), + new InvalidRequestError(), + new LimitExceededError(), + ])('returns true for Snap RPC errors', (error) => { + expect(isSnapRpcError(error)).toBe(true); + }); + + it('returns false for generic errors', () => { + expect(isSnapRpcError(new Error('Unexpected error'))).toBe(false); + }); + + it('returns false for non-error values', () => { + expect(isSnapRpcError('string')).toBe(false); + expect(isSnapRpcError(null)).toBe(false); + }); + }); + + describe('normalizeError', () => { + it('preserves Snap RPC errors without wrapping', () => { + const originalError = new UserRejectedRequestError(); + + expect(normalizeError(originalError)).toBe(originalError); + }); + + it('wraps generic errors in SnapError', () => { + const originalError = new Error('Test error'); + + const normalized = normalizeError(originalError); + + expect(normalized).toBeInstanceOf(SnapError); + expect(normalized.message).toBe('Test error'); + }); + + it('wraps non-Error values in SnapError', () => { + const normalized = normalizeError('string error'); + + expect(normalized).toBeInstanceOf(SnapError); + expect(normalized.message).toBe('string error'); + }); + }); + + describe('createWithCatchAndThrowSnapError', () => { + it('returns the result when the function succeeds', async () => { + const { mockLogger, withCatchAndThrowSnapError } = setupTest(); + const mockFn = jest.fn().mockResolvedValue('success'); + + const result = await withCatchAndThrowSnapError(mockFn); + + expect(result).toBe('success'); + expect(mockFn).toHaveBeenCalledTimes(1); + expect(mockLogger.error).not.toHaveBeenCalled(); + }); + + it('tracks, logs, and re-throws errors as SnapError', async () => { + const { mockLogger, trackError, withCatchAndThrowSnapError } = + setupTest(); + const originalError = new Error('Test error'); + const mockFn = jest.fn().mockRejectedValue(originalError); + + await expect(withCatchAndThrowSnapError(mockFn)).rejects.toThrow( + SnapError, + ); + + expect(trackError).toHaveBeenCalledWith(originalError); + expect(mockLogger.error).toHaveBeenCalledTimes(1); + }); + + it('preserves Snap RPC errors without wrapping', async () => { + const { trackError, withCatchAndThrowSnapError } = setupTest(); + const originalError = new UserRejectedRequestError(); + const mockFn = jest.fn().mockRejectedValue(originalError); + + await expect(withCatchAndThrowSnapError(mockFn)).rejects.toThrow( + UserRejectedRequestError, + ); + + expect(trackError).toHaveBeenCalledWith(originalError); + }); + + it('handles non-Error objects and converts them to SnapError', async () => { + const { mockLogger, withCatchAndThrowSnapError } = setupTest(); + const mockFn = jest.fn().mockRejectedValue('string error'); + + await expect(withCatchAndThrowSnapError(mockFn)).rejects.toThrow( + SnapError, + ); + + expect(mockLogger.error).toHaveBeenCalledTimes(1); + }); + + it('handles null errors', async () => { + const { mockLogger, withCatchAndThrowSnapError } = setupTest(); + const mockFn = jest.fn().mockRejectedValue(null); + + await expect(withCatchAndThrowSnapError(mockFn)).rejects.toThrow( + SnapError, + ); + + expect(mockLogger.error).toHaveBeenCalledTimes(1); + }); + + it('preserves the original error message in the SnapError', async () => { + const { withCatchAndThrowSnapError } = setupTest(); + const originalError = new Error('Custom error message'); + const mockFn = jest.fn().mockRejectedValue(originalError); + + await expect(withCatchAndThrowSnapError(mockFn)).rejects.toThrow( + 'Custom error message', + ); + }); + + it('uses a custom normalizeErrorFn when provided', async () => { + const { trackError, createBoundWithCatchAndThrowSnapError } = setupTest(); + const customError = new MethodNotFoundError(); + const normalizeErrorFn = jest.fn().mockReturnValue(customError); + const bound = createBoundWithCatchAndThrowSnapError({ + trackError, + normalizeErrorFn, + }); + const mockFn = jest.fn().mockRejectedValue(new Error('Test error')); + + await expect(bound(mockFn)).rejects.toThrow(MethodNotFoundError); + + expect(normalizeErrorFn).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Test error' }), + ); + }); + }); +}); diff --git a/packages/snap-networks-utils/src/errors/errors.ts b/packages/snap-networks-utils/src/errors/errors.ts new file mode 100644 index 00000000..233dc32e --- /dev/null +++ b/packages/snap-networks-utils/src/errors/errors.ts @@ -0,0 +1,78 @@ +import { SnapError, getErrorMessage } from '@metamask/snaps-sdk'; + +import type { Logger } from '../logger/Logger'; +import { isSnapRpcError } from './snapRpcError'; +import type { SnapRpcError } from './snapRpcError'; + +/** + * Sends an error to the snap's tracking transport (e.g. `snap_trackError`). + * Whether to invoke this for a given error in a given context is the caller's decision. + */ +export type TrackErrorFn = (error: unknown) => Promise; + +/** + * Converts a caught value into an error suitable for Snap RPC responses. + */ +export type NormalizeErrorFn = (error: unknown) => SnapRpcError; + +/** + * Normalizes an unknown caught value into a Snap RPC error. + * + * Preserves existing Snap RPC errors; otherwise wraps the value in {@link SnapError}. + * + * @param error - The caught value. + * @returns A Snap RPC error. + */ +export function normalizeError(error: unknown): SnapRpcError { + return isSnapRpcError(error) + ? error + : new SnapError(error instanceof Error ? error : getErrorMessage(error)); +} + +export type CreateWithCatchAndThrowSnapErrorOptions = { + logError: Logger['error']; + trackError: TrackErrorFn; + normalizeErrorFn?: NormalizeErrorFn; +}; + +/** + * Creates a handler-boundary error wrapper wired with logger, tracking, and optional error normalization. + * + * @param options - Logger, error-tracking transport, and optional custom normalizer. + * @returns A function that catches errors, tracks them, logs, and rethrows as Snap RPC errors. + */ +export function createWithCatchAndThrowSnapError({ + logError, + trackError, + normalizeErrorFn = normalizeError, +}: CreateWithCatchAndThrowSnapErrorOptions) { + return (fn: () => Promise) => + withCatchAndThrowSnapErrorHandler( + logError, + trackError, + normalizeErrorFn, + fn, + ); +} + +async function withCatchAndThrowSnapErrorHandler( + logError: Logger['error'], + trackError: TrackErrorFn, + normalizeErrorFn: NormalizeErrorFn, + fn: () => Promise, +): Promise { + try { + return await fn(); + } catch (unknownError) { + await trackError(unknownError); + + const error = normalizeErrorFn(unknownError); + + logError( + { error }, + `[SnapError] ${JSON.stringify(error.toJSON(), null, 2)}`, + ); + + throw error; + } +} diff --git a/packages/snap-networks-utils/src/errors/index.ts b/packages/snap-networks-utils/src/errors/index.ts new file mode 100644 index 00000000..c111adc0 --- /dev/null +++ b/packages/snap-networks-utils/src/errors/index.ts @@ -0,0 +1,8 @@ +export { createWithCatchAndThrowSnapError, normalizeError } from './errors'; +export { isSnapRpcError } from './snapRpcError'; +export type { + CreateWithCatchAndThrowSnapErrorOptions, + NormalizeErrorFn, + TrackErrorFn, +} from './errors'; +export type { SnapRpcError } from './snapRpcError'; diff --git a/packages/snap-networks-utils/src/errors/snapRpcError.ts b/packages/snap-networks-utils/src/errors/snapRpcError.ts new file mode 100644 index 00000000..72b321d6 --- /dev/null +++ b/packages/snap-networks-utils/src/errors/snapRpcError.ts @@ -0,0 +1,69 @@ +import { + MethodNotFoundError, + ParseError, + ResourceNotFoundError, + ResourceUnavailableError, + ChainDisconnectedError, + TransactionRejected, + DisconnectedError, + InternalError, + UnauthorizedError, + UnsupportedMethodError, + InvalidInputError, + InvalidParamsError, + InvalidRequestError, + LimitExceededError, + SnapError, + MethodNotSupportedError, + UserRejectedRequestError, +} from '@metamask/snaps-sdk'; + +/** Union of Snap RPC error instance types (for type narrowing). */ +export type SnapRpcError = + | InstanceType + | InstanceType + | InstanceType + | InstanceType + | InstanceType + | InstanceType + | InstanceType + | InstanceType + | InstanceType + | InstanceType + | InstanceType + | InstanceType + | InstanceType + | InstanceType + | InstanceType + | InstanceType + | InstanceType; + +const SNAP_RPC_ERROR_TYPES = [ + SnapError, + MethodNotFoundError, + UserRejectedRequestError, + MethodNotSupportedError, + ParseError, + ResourceNotFoundError, + ResourceUnavailableError, + TransactionRejected, + ChainDisconnectedError, + DisconnectedError, + UnauthorizedError, + UnsupportedMethodError, + InternalError, + InvalidInputError, + InvalidParamsError, + InvalidRequestError, + LimitExceededError, +] as const; + +/** + * Determines if the given error is a Snap RPC error. + * + * @param error - The error instance to be checked. + * @returns A boolean indicating whether the error is a Snap RPC error. + */ +export function isSnapRpcError(error: Error | unknown): error is SnapRpcError { + return SNAP_RPC_ERROR_TYPES.some((errType) => error instanceof errType); +} diff --git a/packages/snap-networks-utils/src/logger/__mocks__/Logger.ts b/packages/snap-networks-utils/src/logger/__mocks__/Logger.ts new file mode 100644 index 00000000..567d9737 --- /dev/null +++ b/packages/snap-networks-utils/src/logger/__mocks__/Logger.ts @@ -0,0 +1,26 @@ +import type { Logger } from '../Logger'; + +export const mockLogger = { + log: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + trace: jest.fn(), + withPrefix: (prefix: string): Logger => createPrefixedLogger([prefix]), +} as unknown as jest.Mocked; + +function createPrefixedLogger(prefixes: string[]): Logger { + return new Proxy(mockLogger, { + get(target, property: keyof Logger): unknown { + if (property === 'withPrefix') { + return (prefix: string) => createPrefixedLogger([...prefixes, prefix]); + } + + const method = target[property]; + return typeof method === 'function' + ? (...args: unknown[]): unknown => method(...prefixes, ...args) + : method; + }, + }); +} From 465e06d6db8d60abf22afa5c722febfe4492a676 Mon Sep 17 00:00:00 2001 From: Andrew Taran Date: Mon, 31 Aug 2026 15:11:55 +0200 Subject: [PATCH 2/6] chore: use shared withCatchAndThrowSnapError in tron snap --- packages/tron-wallet-snap/src/clients/snap/SnapClient.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/tron-wallet-snap/src/clients/snap/SnapClient.test.ts b/packages/tron-wallet-snap/src/clients/snap/SnapClient.test.ts index 0706c5ca..a3174f66 100644 --- a/packages/tron-wallet-snap/src/clients/snap/SnapClient.test.ts +++ b/packages/tron-wallet-snap/src/clients/snap/SnapClient.test.ts @@ -1,3 +1,4 @@ +import { UserRejectedRequestError } from '@metamask/snaps-sdk'; import type { Logger } from '@metamask/snap-networks-utils'; import { UserRejectedRequestError } from '@metamask/snaps-sdk'; From 1107ddd50d3e56c2848968473aadbdff5bae968e Mon Sep 17 00:00:00 2001 From: Andrew Taran Date: Mon, 31 Aug 2026 15:39:52 +0200 Subject: [PATCH 3/6] chore: use shared withCatchAndThrowSnapError in stellar snap --- packages/stellar-wallet-snap/src/utils/errors.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/stellar-wallet-snap/src/utils/errors.ts b/packages/stellar-wallet-snap/src/utils/errors.ts index 622340f7..094afdb7 100644 --- a/packages/stellar-wallet-snap/src/utils/errors.ts +++ b/packages/stellar-wallet-snap/src/utils/errors.ts @@ -2,12 +2,12 @@ import { createWithCatchAndThrowSnapError } from '@metamask/snap-networks-utils' import type { Struct } from '@metamask/superstruct'; import { assert, enums, object, type } from '@metamask/superstruct'; -import { logger } from './logger'; -import { trackError } from './snap'; - export { isSnapRpcError } from '@metamask/snap-networks-utils'; export type { SnapRpcError } from '@metamask/snap-networks-utils'; +import { logger } from './logger'; +import { trackError } from './snap'; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- must accept arbitrary `Error` subclass ctor signatures export type AnyErrorConstructor = abstract new (...args: any[]) => Error; From 95f0d2ed29b1fd9845da526e794a1683994eaa3d Mon Sep 17 00:00:00 2001 From: Andrew Taran Date: Mon, 31 Aug 2026 15:48:12 +0200 Subject: [PATCH 4/6] chore: fix lint errors --- .../src/errors/errors.test.ts | 196 ------------------ .../snap-networks-utils/src/errors/errors.ts | 78 ------- .../snap-networks-utils/src/errors/index.ts | 8 - .../src/errors/snapRpcError.ts | 69 ------ .../src/logger/__mocks__/Logger.ts | 26 --- .../src/utils/errors/errors.ts | 4 +- .../stellar-wallet-snap/src/utils/errors.ts | 6 +- .../src/clients/snap/SnapClient.test.ts | 1 - 8 files changed, 5 insertions(+), 383 deletions(-) delete mode 100644 packages/snap-networks-utils/src/errors/errors.test.ts delete mode 100644 packages/snap-networks-utils/src/errors/errors.ts delete mode 100644 packages/snap-networks-utils/src/errors/index.ts delete mode 100644 packages/snap-networks-utils/src/errors/snapRpcError.ts delete mode 100644 packages/snap-networks-utils/src/logger/__mocks__/Logger.ts diff --git a/packages/snap-networks-utils/src/errors/errors.test.ts b/packages/snap-networks-utils/src/errors/errors.test.ts deleted file mode 100644 index d857b1af..00000000 --- a/packages/snap-networks-utils/src/errors/errors.test.ts +++ /dev/null @@ -1,196 +0,0 @@ -import { - ChainDisconnectedError, - DisconnectedError, - InternalError, - InvalidInputError, - InvalidParamsError, - InvalidRequestError, - LimitExceededError, - MethodNotFoundError, - MethodNotSupportedError, - ParseError, - ResourceNotFoundError, - ResourceUnavailableError, - SnapError, - TransactionRejected, - UnauthorizedError, - UnsupportedMethodError, - UserRejectedRequestError, -} from '@metamask/snaps-sdk'; - -import { mockLogger } from '../logger/__mocks__/Logger'; -import { createWithCatchAndThrowSnapError, normalizeError } from './errors'; -import type { CreateWithCatchAndThrowSnapErrorOptions } from './errors'; -import { isSnapRpcError } from './snapRpcError'; - -const setupTest = () => { - jest.clearAllMocks(); - - const trackError = jest.fn(); - const withCatchAndThrowSnapError = createWithCatchAndThrowSnapError({ - logError: mockLogger['error'], - trackError, - }); - - return { - mockLogger, - trackError, - withCatchAndThrowSnapError, - createBoundWithCatchAndThrowSnapError: ( - options: Omit = { - trackError, - }, - ) => - createWithCatchAndThrowSnapError({ - ...options, - logError: mockLogger['error'], - }), - }; -}; - -describe('errors', () => { - describe('isSnapRpcError', () => { - it.each([ - new SnapError('Test error'), - new MethodNotFoundError(), - new UserRejectedRequestError(), - new MethodNotSupportedError(), - new ParseError(), - new ResourceNotFoundError(), - new ResourceUnavailableError(), - new TransactionRejected(), - new ChainDisconnectedError(), - new DisconnectedError(), - new UnauthorizedError(), - new UnsupportedMethodError(), - new InternalError(), - new InvalidInputError(), - new InvalidParamsError(), - new InvalidRequestError(), - new LimitExceededError(), - ])('returns true for Snap RPC errors', (error) => { - expect(isSnapRpcError(error)).toBe(true); - }); - - it('returns false for generic errors', () => { - expect(isSnapRpcError(new Error('Unexpected error'))).toBe(false); - }); - - it('returns false for non-error values', () => { - expect(isSnapRpcError('string')).toBe(false); - expect(isSnapRpcError(null)).toBe(false); - }); - }); - - describe('normalizeError', () => { - it('preserves Snap RPC errors without wrapping', () => { - const originalError = new UserRejectedRequestError(); - - expect(normalizeError(originalError)).toBe(originalError); - }); - - it('wraps generic errors in SnapError', () => { - const originalError = new Error('Test error'); - - const normalized = normalizeError(originalError); - - expect(normalized).toBeInstanceOf(SnapError); - expect(normalized.message).toBe('Test error'); - }); - - it('wraps non-Error values in SnapError', () => { - const normalized = normalizeError('string error'); - - expect(normalized).toBeInstanceOf(SnapError); - expect(normalized.message).toBe('string error'); - }); - }); - - describe('createWithCatchAndThrowSnapError', () => { - it('returns the result when the function succeeds', async () => { - const { mockLogger, withCatchAndThrowSnapError } = setupTest(); - const mockFn = jest.fn().mockResolvedValue('success'); - - const result = await withCatchAndThrowSnapError(mockFn); - - expect(result).toBe('success'); - expect(mockFn).toHaveBeenCalledTimes(1); - expect(mockLogger.error).not.toHaveBeenCalled(); - }); - - it('tracks, logs, and re-throws errors as SnapError', async () => { - const { mockLogger, trackError, withCatchAndThrowSnapError } = - setupTest(); - const originalError = new Error('Test error'); - const mockFn = jest.fn().mockRejectedValue(originalError); - - await expect(withCatchAndThrowSnapError(mockFn)).rejects.toThrow( - SnapError, - ); - - expect(trackError).toHaveBeenCalledWith(originalError); - expect(mockLogger.error).toHaveBeenCalledTimes(1); - }); - - it('preserves Snap RPC errors without wrapping', async () => { - const { trackError, withCatchAndThrowSnapError } = setupTest(); - const originalError = new UserRejectedRequestError(); - const mockFn = jest.fn().mockRejectedValue(originalError); - - await expect(withCatchAndThrowSnapError(mockFn)).rejects.toThrow( - UserRejectedRequestError, - ); - - expect(trackError).toHaveBeenCalledWith(originalError); - }); - - it('handles non-Error objects and converts them to SnapError', async () => { - const { mockLogger, withCatchAndThrowSnapError } = setupTest(); - const mockFn = jest.fn().mockRejectedValue('string error'); - - await expect(withCatchAndThrowSnapError(mockFn)).rejects.toThrow( - SnapError, - ); - - expect(mockLogger.error).toHaveBeenCalledTimes(1); - }); - - it('handles null errors', async () => { - const { mockLogger, withCatchAndThrowSnapError } = setupTest(); - const mockFn = jest.fn().mockRejectedValue(null); - - await expect(withCatchAndThrowSnapError(mockFn)).rejects.toThrow( - SnapError, - ); - - expect(mockLogger.error).toHaveBeenCalledTimes(1); - }); - - it('preserves the original error message in the SnapError', async () => { - const { withCatchAndThrowSnapError } = setupTest(); - const originalError = new Error('Custom error message'); - const mockFn = jest.fn().mockRejectedValue(originalError); - - await expect(withCatchAndThrowSnapError(mockFn)).rejects.toThrow( - 'Custom error message', - ); - }); - - it('uses a custom normalizeErrorFn when provided', async () => { - const { trackError, createBoundWithCatchAndThrowSnapError } = setupTest(); - const customError = new MethodNotFoundError(); - const normalizeErrorFn = jest.fn().mockReturnValue(customError); - const bound = createBoundWithCatchAndThrowSnapError({ - trackError, - normalizeErrorFn, - }); - const mockFn = jest.fn().mockRejectedValue(new Error('Test error')); - - await expect(bound(mockFn)).rejects.toThrow(MethodNotFoundError); - - expect(normalizeErrorFn).toHaveBeenCalledWith( - expect.objectContaining({ message: 'Test error' }), - ); - }); - }); -}); diff --git a/packages/snap-networks-utils/src/errors/errors.ts b/packages/snap-networks-utils/src/errors/errors.ts deleted file mode 100644 index 233dc32e..00000000 --- a/packages/snap-networks-utils/src/errors/errors.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { SnapError, getErrorMessage } from '@metamask/snaps-sdk'; - -import type { Logger } from '../logger/Logger'; -import { isSnapRpcError } from './snapRpcError'; -import type { SnapRpcError } from './snapRpcError'; - -/** - * Sends an error to the snap's tracking transport (e.g. `snap_trackError`). - * Whether to invoke this for a given error in a given context is the caller's decision. - */ -export type TrackErrorFn = (error: unknown) => Promise; - -/** - * Converts a caught value into an error suitable for Snap RPC responses. - */ -export type NormalizeErrorFn = (error: unknown) => SnapRpcError; - -/** - * Normalizes an unknown caught value into a Snap RPC error. - * - * Preserves existing Snap RPC errors; otherwise wraps the value in {@link SnapError}. - * - * @param error - The caught value. - * @returns A Snap RPC error. - */ -export function normalizeError(error: unknown): SnapRpcError { - return isSnapRpcError(error) - ? error - : new SnapError(error instanceof Error ? error : getErrorMessage(error)); -} - -export type CreateWithCatchAndThrowSnapErrorOptions = { - logError: Logger['error']; - trackError: TrackErrorFn; - normalizeErrorFn?: NormalizeErrorFn; -}; - -/** - * Creates a handler-boundary error wrapper wired with logger, tracking, and optional error normalization. - * - * @param options - Logger, error-tracking transport, and optional custom normalizer. - * @returns A function that catches errors, tracks them, logs, and rethrows as Snap RPC errors. - */ -export function createWithCatchAndThrowSnapError({ - logError, - trackError, - normalizeErrorFn = normalizeError, -}: CreateWithCatchAndThrowSnapErrorOptions) { - return (fn: () => Promise) => - withCatchAndThrowSnapErrorHandler( - logError, - trackError, - normalizeErrorFn, - fn, - ); -} - -async function withCatchAndThrowSnapErrorHandler( - logError: Logger['error'], - trackError: TrackErrorFn, - normalizeErrorFn: NormalizeErrorFn, - fn: () => Promise, -): Promise { - try { - return await fn(); - } catch (unknownError) { - await trackError(unknownError); - - const error = normalizeErrorFn(unknownError); - - logError( - { error }, - `[SnapError] ${JSON.stringify(error.toJSON(), null, 2)}`, - ); - - throw error; - } -} diff --git a/packages/snap-networks-utils/src/errors/index.ts b/packages/snap-networks-utils/src/errors/index.ts deleted file mode 100644 index c111adc0..00000000 --- a/packages/snap-networks-utils/src/errors/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export { createWithCatchAndThrowSnapError, normalizeError } from './errors'; -export { isSnapRpcError } from './snapRpcError'; -export type { - CreateWithCatchAndThrowSnapErrorOptions, - NormalizeErrorFn, - TrackErrorFn, -} from './errors'; -export type { SnapRpcError } from './snapRpcError'; diff --git a/packages/snap-networks-utils/src/errors/snapRpcError.ts b/packages/snap-networks-utils/src/errors/snapRpcError.ts deleted file mode 100644 index 72b321d6..00000000 --- a/packages/snap-networks-utils/src/errors/snapRpcError.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { - MethodNotFoundError, - ParseError, - ResourceNotFoundError, - ResourceUnavailableError, - ChainDisconnectedError, - TransactionRejected, - DisconnectedError, - InternalError, - UnauthorizedError, - UnsupportedMethodError, - InvalidInputError, - InvalidParamsError, - InvalidRequestError, - LimitExceededError, - SnapError, - MethodNotSupportedError, - UserRejectedRequestError, -} from '@metamask/snaps-sdk'; - -/** Union of Snap RPC error instance types (for type narrowing). */ -export type SnapRpcError = - | InstanceType - | InstanceType - | InstanceType - | InstanceType - | InstanceType - | InstanceType - | InstanceType - | InstanceType - | InstanceType - | InstanceType - | InstanceType - | InstanceType - | InstanceType - | InstanceType - | InstanceType - | InstanceType - | InstanceType; - -const SNAP_RPC_ERROR_TYPES = [ - SnapError, - MethodNotFoundError, - UserRejectedRequestError, - MethodNotSupportedError, - ParseError, - ResourceNotFoundError, - ResourceUnavailableError, - TransactionRejected, - ChainDisconnectedError, - DisconnectedError, - UnauthorizedError, - UnsupportedMethodError, - InternalError, - InvalidInputError, - InvalidParamsError, - InvalidRequestError, - LimitExceededError, -] as const; - -/** - * Determines if the given error is a Snap RPC error. - * - * @param error - The error instance to be checked. - * @returns A boolean indicating whether the error is a Snap RPC error. - */ -export function isSnapRpcError(error: Error | unknown): error is SnapRpcError { - return SNAP_RPC_ERROR_TYPES.some((errType) => error instanceof errType); -} diff --git a/packages/snap-networks-utils/src/logger/__mocks__/Logger.ts b/packages/snap-networks-utils/src/logger/__mocks__/Logger.ts deleted file mode 100644 index 567d9737..00000000 --- a/packages/snap-networks-utils/src/logger/__mocks__/Logger.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { Logger } from '../Logger'; - -export const mockLogger = { - log: jest.fn(), - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - debug: jest.fn(), - trace: jest.fn(), - withPrefix: (prefix: string): Logger => createPrefixedLogger([prefix]), -} as unknown as jest.Mocked; - -function createPrefixedLogger(prefixes: string[]): Logger { - return new Proxy(mockLogger, { - get(target, property: keyof Logger): unknown { - if (property === 'withPrefix') { - return (prefix: string) => createPrefixedLogger([...prefixes, prefix]); - } - - const method = target[property]; - return typeof method === 'function' - ? (...args: unknown[]): unknown => method(...prefixes, ...args) - : method; - }, - }); -} diff --git a/packages/snap-networks-utils/src/utils/errors/errors.ts b/packages/snap-networks-utils/src/utils/errors/errors.ts index 7a32c037..1053ee71 100644 --- a/packages/snap-networks-utils/src/utils/errors/errors.ts +++ b/packages/snap-networks-utils/src/utils/errors/errors.ts @@ -32,7 +32,7 @@ export function normalizeError(error: unknown): SnapRpcError { export type LogErrorFn = Logger['error']; export type CreateWithCatchAndThrowSnapErrorOptions = { - logError: LogErrorFn; + logError: Logger['error']; trackError: TrackErrorFn; normalizeErrorFn?: NormalizeErrorFn; }; @@ -67,7 +67,7 @@ export function createWithCatchAndThrowSnapError({ } async function withCatchAndThrowSnapErrorHandler( - logError: LogErrorFn, + logError: Logger['error'], trackError: TrackErrorFn, normalizeErrorFn: NormalizeErrorFn, fn: () => Promise, diff --git a/packages/stellar-wallet-snap/src/utils/errors.ts b/packages/stellar-wallet-snap/src/utils/errors.ts index 094afdb7..622340f7 100644 --- a/packages/stellar-wallet-snap/src/utils/errors.ts +++ b/packages/stellar-wallet-snap/src/utils/errors.ts @@ -2,12 +2,12 @@ import { createWithCatchAndThrowSnapError } from '@metamask/snap-networks-utils' import type { Struct } from '@metamask/superstruct'; import { assert, enums, object, type } from '@metamask/superstruct'; -export { isSnapRpcError } from '@metamask/snap-networks-utils'; -export type { SnapRpcError } from '@metamask/snap-networks-utils'; - import { logger } from './logger'; import { trackError } from './snap'; +export { isSnapRpcError } from '@metamask/snap-networks-utils'; +export type { SnapRpcError } from '@metamask/snap-networks-utils'; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- must accept arbitrary `Error` subclass ctor signatures export type AnyErrorConstructor = abstract new (...args: any[]) => Error; diff --git a/packages/tron-wallet-snap/src/clients/snap/SnapClient.test.ts b/packages/tron-wallet-snap/src/clients/snap/SnapClient.test.ts index a3174f66..0706c5ca 100644 --- a/packages/tron-wallet-snap/src/clients/snap/SnapClient.test.ts +++ b/packages/tron-wallet-snap/src/clients/snap/SnapClient.test.ts @@ -1,4 +1,3 @@ -import { UserRejectedRequestError } from '@metamask/snaps-sdk'; import type { Logger } from '@metamask/snap-networks-utils'; import { UserRejectedRequestError } from '@metamask/snaps-sdk'; From 8c8d1054c0d1600933c79724f560de2c80dda196 Mon Sep 17 00:00:00 2001 From: Andrew Taran Date: Tue, 1 Sep 2026 12:54:17 +0200 Subject: [PATCH 5/6] feat: add optional logError override --- packages/snap-networks-utils/src/utils/errors/errors.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/snap-networks-utils/src/utils/errors/errors.ts b/packages/snap-networks-utils/src/utils/errors/errors.ts index 1053ee71..7a32c037 100644 --- a/packages/snap-networks-utils/src/utils/errors/errors.ts +++ b/packages/snap-networks-utils/src/utils/errors/errors.ts @@ -32,7 +32,7 @@ export function normalizeError(error: unknown): SnapRpcError { export type LogErrorFn = Logger['error']; export type CreateWithCatchAndThrowSnapErrorOptions = { - logError: Logger['error']; + logError: LogErrorFn; trackError: TrackErrorFn; normalizeErrorFn?: NormalizeErrorFn; }; @@ -67,7 +67,7 @@ export function createWithCatchAndThrowSnapError({ } async function withCatchAndThrowSnapErrorHandler( - logError: Logger['error'], + logError: LogErrorFn, trackError: TrackErrorFn, normalizeErrorFn: NormalizeErrorFn, fn: () => Promise, From 84b9966b2b9c291d2b6c17917c32f42db4be226f Mon Sep 17 00:00:00 2001 From: Andrew Taran Date: Tue, 1 Sep 2026 17:23:02 +0200 Subject: [PATCH 6/6] feat: add shared trackError util --- packages/snap-networks-utils/CHANGELOG.md | 3 + packages/snap-networks-utils/src/index.ts | 8 + .../src/utils/errors/index.ts | 9 + .../src/utils/errors/trackError.test.ts | 245 ++++++++++++++++++ .../src/utils/errors/trackError.ts | 143 ++++++++++ packages/solana-wallet-snap/CHANGELOG.md | 1 + .../solana-wallet-snap/snap.manifest.json | 2 +- .../src/core/utils/errors.test.ts | 4 +- .../src/core/utils/errors.ts | 47 +--- .../solana-wallet-snap/src/core/utils/snap.ts | 11 + 10 files changed, 431 insertions(+), 42 deletions(-) create mode 100644 packages/snap-networks-utils/src/utils/errors/trackError.test.ts create mode 100644 packages/snap-networks-utils/src/utils/errors/trackError.ts create mode 100644 packages/solana-wallet-snap/src/core/utils/snap.ts diff --git a/packages/snap-networks-utils/CHANGELOG.md b/packages/snap-networks-utils/CHANGELOG.md index 1dfdc53a..005d957e 100644 --- a/packages/snap-networks-utils/CHANGELOG.md +++ b/packages/snap-networks-utils/CHANGELOG.md @@ -15,6 +15,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `createWithCatchAndThrowSnapError` for handler-boundary error catching, logging, and Snap RPC normalization - `normalizeError` for converting caught values into Snap RPC errors, with optional custom normalizers via `createWithCatchAndThrowSnapError`'s `normalizeErrorFn` option - `isSnapRpcError` type guard and `SnapRpcError` union type +- Add shared Sentry error tracking utilities for network snaps ([#246](https://github.com/MetaMask/internal-snaps/pull/246)) + - `createTrackError` for reporting errors to MetaMask via `snap_trackError` + - `createSnapErrorHandling` to wire `createTrackError` and `createWithCatchAndThrowSnapError` - Add `InFlightCoalescer`, exported from a new `./dedupe` entry point, which coalesces concurrent async operations by key so callers share one in-flight run ([#149](https://github.com/MetaMask/internal-snaps/pull/149)) - Add shared async batching utilities. ([#211](https://github.com/MetaMask/internal-snaps/pull/211)) - Add origin permission helpers ([#193](https://github.com/MetaMask/internal-snaps/pull/193)) diff --git a/packages/snap-networks-utils/src/index.ts b/packages/snap-networks-utils/src/index.ts index 941df0cb..a2b21e45 100644 --- a/packages/snap-networks-utils/src/index.ts +++ b/packages/snap-networks-utils/src/index.ts @@ -43,14 +43,22 @@ export { export type { CreateOriginPermissionsParams } from './utils/originPermissions/createOriginPermissions'; export { validateOrigin } from './utils/originPermissions/validateOrigin'; export { + createSnapErrorHandling, + createTrackError, createWithCatchAndThrowSnapError, isSnapRpcError, normalizeError, } from './utils/errors'; export type { + CreateSnapErrorHandlingOptions, + CreateTrackErrorOptions, CreateWithCatchAndThrowSnapErrorOptions, LogErrorFn, NormalizeErrorFn, + PrepareErrorForTrackingFn, + ShouldTrackErrorFn, SnapRpcError, + SnapTrackErrorRequest, + TrackErrorCapableProvider, TrackErrorFn, } from './utils/errors'; diff --git a/packages/snap-networks-utils/src/utils/errors/index.ts b/packages/snap-networks-utils/src/utils/errors/index.ts index 582480c9..f057effe 100644 --- a/packages/snap-networks-utils/src/utils/errors/index.ts +++ b/packages/snap-networks-utils/src/utils/errors/index.ts @@ -1,5 +1,14 @@ +export { createSnapErrorHandling, createTrackError } from './trackError'; export { createWithCatchAndThrowSnapError, normalizeError } from './errors'; export { isSnapRpcError } from './snapRpcError'; +export type { + CreateSnapErrorHandlingOptions, + CreateTrackErrorOptions, + PrepareErrorForTrackingFn, + ShouldTrackErrorFn, + SnapTrackErrorRequest, + TrackErrorCapableProvider, +} from './trackError'; export type { CreateWithCatchAndThrowSnapErrorOptions, LogErrorFn, diff --git a/packages/snap-networks-utils/src/utils/errors/trackError.test.ts b/packages/snap-networks-utils/src/utils/errors/trackError.test.ts new file mode 100644 index 00000000..8f84ebe0 --- /dev/null +++ b/packages/snap-networks-utils/src/utils/errors/trackError.test.ts @@ -0,0 +1,245 @@ +import { SnapError, UserRejectedRequestError } from '@metamask/snaps-sdk'; + +import { mockLogger } from '../logger/__mocks__/Logger'; +import type { TrackErrorFn } from './errors'; +import { createSnapErrorHandling, createTrackError } from './trackError'; +import type { TrackErrorCapableProvider } from './trackError'; + +const setupTrackErrorTest = (): { + request: jest.Mock; + trackError: TrackErrorFn; +} => { + jest.clearAllMocks(); + + const request = jest.fn(); + const getSnapProvider = (): TrackErrorCapableProvider => ({ request }); + const trackError = createTrackError({ + getSnapProvider, + logError: mockLogger.error.bind(mockLogger), + }); + + return { request, trackError }; +}; + +describe('trackError', () => { + describe('createTrackError', () => { + it('does not track UserRejectedRequestError', async () => { + const { request, trackError } = setupTrackErrorTest(); + + expect(await trackError(new UserRejectedRequestError())).toBeUndefined(); + + expect(request).not.toHaveBeenCalled(); + }); + + it('tracks errors and returns the Sentry event id', async () => { + const { request, trackError } = setupTrackErrorTest(); + const originalError = new Error('Test error'); + request.mockResolvedValue('tracked-error-id'); + + expect(await trackError(originalError)).toBe('tracked-error-id'); + + expect(request).toHaveBeenCalledWith({ + method: 'snap_trackError', + params: { + error: expect.objectContaining({ + message: originalError.message, + }), + }, + }); + expect(mockLogger.error).not.toHaveBeenCalled(); + }); + + it('does not throw when tracking fails', async () => { + const { request, trackError } = setupTrackErrorTest(); + const originalError = new Error('Test error'); + const trackingError = new Error('Tracking failed'); + request.mockRejectedValue(trackingError); + + expect(await trackError(originalError)).toBeUndefined(); + + expect(mockLogger.error).toHaveBeenCalledWith( + { error: trackingError }, + 'Failed to track error', + ); + }); + + it('uses prepareError before serializing', async () => { + const request = jest.fn().mockResolvedValue('tracked-error-id'); + const prepareError = jest.fn( + (error: unknown): Error => new Error(`wrapped: ${String(error)}`), + ); + const getSnapProvider = (): TrackErrorCapableProvider => ({ request }); + const trackError = createTrackError({ + getSnapProvider, + logError: mockLogger.error.bind(mockLogger), + prepareError, + }); + + await trackError('raw error'); + + expect(prepareError).toHaveBeenCalledWith('raw error'); + expect(request).toHaveBeenCalledWith({ + method: 'snap_trackError', + params: { + error: expect.objectContaining({ + message: 'wrapped: raw error', + }), + }, + }); + }); + + it('uses shouldTrack to skip tracking', async () => { + const request = jest.fn(); + const getSnapProvider = (): TrackErrorCapableProvider => ({ request }); + const trackError = createTrackError({ + getSnapProvider, + logError: mockLogger.error.bind(mockLogger), + shouldTrack: (error: unknown): boolean => error instanceof SnapError, + }); + + expect(await trackError(new Error('ignored'))).toBeUndefined(); + expect(request).not.toHaveBeenCalled(); + + request.mockResolvedValue('tracked-error-id'); + expect(await trackError(new SnapError('tracked'))).toBe( + 'tracked-error-id', + ); + }); + + it('coerces non-Error values with the default prepareError', async () => { + const { request, trackError } = setupTrackErrorTest(); + request.mockResolvedValue('tracked-error-id'); + + expect(await trackError('string error')).toBe('tracked-error-id'); + + expect(request).toHaveBeenCalledWith({ + method: 'snap_trackError', + params: { + error: expect.objectContaining({ + cause: expect.objectContaining({ + message: 'string error', + }), + }), + }, + }); + }); + + it('resolves the snap provider on each call', async () => { + const firstRequest = jest.fn().mockResolvedValue('first-id'); + const secondRequest = jest.fn().mockResolvedValue('second-id'); + const getSnapProvider = jest + .fn() + .mockReturnValueOnce({ request: firstRequest }) + .mockReturnValueOnce({ request: secondRequest }); + const trackError = createTrackError({ + getSnapProvider, + logError: mockLogger.error.bind(mockLogger), + }); + + expect(await trackError(new Error('first'))).toBe('first-id'); + expect(await trackError(new Error('second'))).toBe('second-id'); + expect(getSnapProvider).toHaveBeenCalledTimes(2); + }); + }); + + describe('createSnapErrorHandling', () => { + it('wires trackError into withCatchAndThrowSnapError', async () => { + jest.clearAllMocks(); + + const request = jest.fn().mockResolvedValue('tracked-error-id'); + const getSnapProvider = (): TrackErrorCapableProvider => ({ request }); + const { trackError, withCatchAndThrowSnapError } = + createSnapErrorHandling({ + getSnapProvider, + logError: mockLogger.error.bind(mockLogger), + }); + + const originalError = new Error('Test error'); + const mockFn = jest.fn().mockRejectedValue(originalError); + + await expect(withCatchAndThrowSnapError(mockFn)).rejects.toThrow( + SnapError, + ); + + expect(request).toHaveBeenCalledTimes(1); + expect(mockLogger.error).toHaveBeenCalledTimes(1); + expect(await trackError(originalError)).toBe('tracked-error-id'); + }); + + it('forwards prepareError and shouldTrack to trackError', async () => { + jest.clearAllMocks(); + + const request = jest.fn().mockResolvedValue('tracked-error-id'); + const getSnapProvider = (): TrackErrorCapableProvider => ({ request }); + const prepareError = jest.fn( + (error: unknown): Error => new Error(`prepared: ${String(error)}`), + ); + const shouldTrack = jest.fn( + (error: unknown): boolean => error instanceof Error, + ); + const { trackError } = createSnapErrorHandling({ + getSnapProvider, + logError: mockLogger.error.bind(mockLogger), + prepareError, + shouldTrack, + }); + + expect(await trackError('skip me')).toBeUndefined(); + expect(shouldTrack).toHaveBeenCalledWith('skip me'); + expect(prepareError).not.toHaveBeenCalled(); + expect(request).not.toHaveBeenCalled(); + + expect(await trackError(new Error('track me'))).toBe('tracked-error-id'); + expect(prepareError).toHaveBeenCalledWith(expect.any(Error)); + expect(request).toHaveBeenCalledTimes(1); + }); + + it('uses a custom normalizeErrorFn in withCatchAndThrowSnapError', async () => { + jest.clearAllMocks(); + + const request = jest.fn().mockResolvedValue('tracked-error-id'); + const getSnapProvider = (): TrackErrorCapableProvider => ({ request }); + const normalizeErrorFn = jest.fn( + () => new UserRejectedRequestError('normalized'), + ); + const { withCatchAndThrowSnapError } = createSnapErrorHandling({ + getSnapProvider, + logError: mockLogger.error.bind(mockLogger), + normalizeErrorFn, + }); + + await expect( + withCatchAndThrowSnapError(async () => { + throw new Error('original'); + }), + ).rejects.toThrow(UserRejectedRequestError); + + expect(normalizeErrorFn).toHaveBeenCalledWith( + expect.objectContaining({ message: 'original' }), + ); + expect(request).toHaveBeenCalledTimes(1); + }); + + it('uses a custom logError override in withCatchAndThrowSnapError', async () => { + jest.clearAllMocks(); + + const request = jest.fn().mockResolvedValue('tracked-error-id'); + const getSnapProvider = (): TrackErrorCapableProvider => ({ request }); + const customLogError = jest.fn(); + const { withCatchAndThrowSnapError } = createSnapErrorHandling({ + getSnapProvider, + logError: mockLogger.error.bind(mockLogger), + }); + const originalError = new Error('Test error'); + + await expect( + withCatchAndThrowSnapError(async () => { + throw originalError; + }, customLogError), + ).rejects.toThrow(SnapError); + + expect(customLogError).toHaveBeenCalledTimes(1); + expect(mockLogger.error).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/snap-networks-utils/src/utils/errors/trackError.ts b/packages/snap-networks-utils/src/utils/errors/trackError.ts new file mode 100644 index 00000000..e772ca04 --- /dev/null +++ b/packages/snap-networks-utils/src/utils/errors/trackError.ts @@ -0,0 +1,143 @@ +import { getJsonError, UserRejectedRequestError } from '@metamask/snaps-sdk'; +import type { TrackErrorParams, TrackErrorResult } from '@metamask/snaps-sdk'; +import { ensureError } from '@metamask/utils'; + +import type { + CreateWithCatchAndThrowSnapErrorOptions, + LogErrorFn, + TrackErrorFn, +} from './errors'; +import { createWithCatchAndThrowSnapError } from './errors'; + +/** + * Converts a caught value into an {@link Error} before Sentry serialization. + */ +export type PrepareErrorForTrackingFn = (error: unknown) => Error; + +/** + * Determines whether an error should be reported through `snap_trackError`. + */ +export type ShouldTrackErrorFn = (error: unknown) => boolean; + +/** + * `snap_trackError` request shape from `@metamask/snaps-sdk`. + */ +export type SnapTrackErrorRequest = { + method: 'snap_trackError'; + params: TrackErrorParams; +}; + +/** + * Snap provider surface required for {@link createTrackError}, derived from SDK + * `snap_trackError` types. + */ +export type TrackErrorCapableProvider = { + request: ( + args: SnapTrackErrorRequest, + ) => Promise; +}; + +export type CreateTrackErrorOptions< + TProvider extends TrackErrorCapableProvider = TrackErrorCapableProvider, +> = { + getSnapProvider: () => TProvider; + logError: LogErrorFn; + prepareError?: PrepareErrorForTrackingFn; + shouldTrack?: ShouldTrackErrorFn; +}; + +export type CreateSnapErrorHandlingOptions< + TProvider extends TrackErrorCapableProvider = TrackErrorCapableProvider, +> = Omit & + CreateTrackErrorOptions; + +function defaultPrepareError(error: unknown): Error { + return error instanceof Error ? error : ensureError(error); +} + +function defaultShouldTrack(error: unknown): boolean { + return !(error instanceof UserRejectedRequestError); +} + +/** + * Creates a snap-bound error reporter for MetaMask Sentry (`snap_trackError`). + * + * RPC failures are caught and logged but never rethrown, so the returned function + * is safe to call from already-failing error-handling paths. + * + * @param options - Snap provider accessor, logger, and optional hooks. + * @param options.getSnapProvider - Returns the Snap provider used for `snap_trackError`. + * @param options.logError - Logger method used when the tracking RPC fails. + * @param options.prepareError - Optional error normalizer before Sentry serialization. + * @param options.shouldTrack - Optional filter; defaults to skipping `UserRejectedRequestError`. + * @returns A function that reports errors to Sentry when {@link ShouldTrackErrorFn} allows it. + */ +export function createTrackError({ + getSnapProvider, + logError, + prepareError = defaultPrepareError, + shouldTrack = defaultShouldTrack, +}: CreateTrackErrorOptions): TrackErrorFn { + return async (error: unknown): Promise => { + if (!shouldTrack(error)) { + return undefined; + } + + const params: TrackErrorParams = { + error: getJsonError(prepareError(error)), + }; + + try { + return await getSnapProvider().request({ + method: 'snap_trackError', + params, + }); + } catch (trackingError) { + logError({ error: trackingError }, 'Failed to track error'); + return undefined; + } + }; +} + +/** + * Creates snap error handling utilities wired with a shared transport and logger setup. + * + * @param options - Options for both {@link createTrackError} and + * {@link createWithCatchAndThrowSnapError}. + * @param options.logError - Logger method used for handler and tracking failures. + * @param options.normalizeErrorFn - Optional Snap RPC error normalizer for handler boundaries. + * @param options.getSnapProvider - Returns the Snap provider used for `snap_trackError`. + * @param options.prepareError - Optional error normalizer before Sentry serialization. + * @param options.shouldTrack - Optional tracking filter passed to {@link createTrackError}. + * @returns Bound `trackError` and `withCatchAndThrowSnapError` functions. + */ +export function createSnapErrorHandling< + TProvider extends TrackErrorCapableProvider, +>({ + logError, + normalizeErrorFn, + getSnapProvider, + prepareError, + shouldTrack, +}: CreateSnapErrorHandlingOptions): { + trackError: TrackErrorFn; + withCatchAndThrowSnapError: ReturnType< + typeof createWithCatchAndThrowSnapError + >; +} { + const trackError = createTrackError({ + getSnapProvider, + logError, + prepareError, + shouldTrack, + }); + + return { + trackError, + withCatchAndThrowSnapError: createWithCatchAndThrowSnapError({ + logError, + trackError, + normalizeErrorFn, + }), + }; +} diff --git a/packages/solana-wallet-snap/CHANGELOG.md b/packages/solana-wallet-snap/CHANGELOG.md index 43395840..2858f367 100644 --- a/packages/solana-wallet-snap/CHANGELOG.md +++ b/packages/solana-wallet-snap/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Migrate `trackError` and `withCatchAndThrowSnapError` to `@metamask/snap-networks-utils` `createSnapErrorHandling`, and add `getSnapProvider` for Snap RPC access - Extract Snap-owned assets domain logic into `SnapAssetsAdapter`; `AssetsService` is a thin facade that delegates metadata, market data, fetch, persist, and account asset reads through the adapter (no Core routing yet). ([#121](https://github.com/MetaMask/internal-snaps/pull/121)) - Align `AssetsService` read API with `snap-networks-utils` / AssetsController shapes by adding `getAccountAssetByID`, `getAccountAssetsByIDs`, `getAccountAssetsByScope`, and `getAccountAssets`, and routing Keyring and Send through them (still Snap-owned storage). ([#120](https://github.com/MetaMask/internal-snaps/pull/120)) - Bump `@metamask/utils` from `^11.9.0` to `^11.11.9` ([#161](https://github.com/MetaMask/internal-snaps/pull/161)) diff --git a/packages/solana-wallet-snap/snap.manifest.json b/packages/solana-wallet-snap/snap.manifest.json index bea1d886..a309f3d0 100644 --- a/packages/solana-wallet-snap/snap.manifest.json +++ b/packages/solana-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/internal-snaps.git" }, "source": { - "shasum": "yxd4HvExG2dvJVH5uajNfTzGfaybRnywIRZk8orqZqo=", + "shasum": "RP8K+BdAavCCQ3ZxXRx/CgH4ipA4Y2K/yCmqQTgHB8w=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/solana-wallet-snap/src/core/utils/errors.test.ts b/packages/solana-wallet-snap/src/core/utils/errors.test.ts index af526720..e9da9e4f 100644 --- a/packages/solana-wallet-snap/src/core/utils/errors.test.ts +++ b/packages/solana-wallet-snap/src/core/utils/errors.test.ts @@ -47,7 +47,7 @@ describe('errors', () => { }), }, }); - expect(mockLogger.warn).toHaveBeenCalledWith( + expect(mockLogger.error).toHaveBeenCalledWith( { error: trackingError, }, @@ -71,7 +71,7 @@ describe('errors', () => { }), }, }); - expect(mockLogger.warn).not.toHaveBeenCalled(); + expect(mockLogger.error).not.toHaveBeenCalled(); }); }); diff --git a/packages/solana-wallet-snap/src/core/utils/errors.ts b/packages/solana-wallet-snap/src/core/utils/errors.ts index b4c1bb94..53dfbf6b 100644 --- a/packages/solana-wallet-snap/src/core/utils/errors.ts +++ b/packages/solana-wallet-snap/src/core/utils/errors.ts @@ -1,44 +1,13 @@ -import { - createWithCatchAndThrowSnapError, - isSnapRpcError, -} from '@metamask/snap-networks-utils'; -import { UserRejectedRequestError, getJsonError } from '@metamask/snaps-sdk'; +import { createSnapErrorHandling } from '@metamask/snap-networks-utils'; import logger from './logger'; +import { getSnapProvider } from './snap'; -export { isSnapRpcError }; +export { isSnapRpcError } from '@metamask/snap-networks-utils'; export type { SnapRpcError } from '@metamask/snap-networks-utils'; -/** - * Reports an error to MetaMask via Sentry (`snap_trackError`). - * - * Skips user rejections. Callers decide whether to invoke this for a given - * error in a given context. - * - * @param error - The error to report. - * @returns The Sentry event ID on success, or `undefined` on failure or if the error is skipped. - */ -export const trackError = async ( - error: unknown, -): Promise => { - if (error instanceof UserRejectedRequestError) { - return undefined; - } - - try { - return await snap.request({ - method: 'snap_trackError', - params: { - error: getJsonError(error), - }, - }); - } catch (trackingError) { - logger.warn({ error: trackingError }, 'Failed to track error'); - return undefined; - } -}; - -export const withCatchAndThrowSnapError = createWithCatchAndThrowSnapError({ - logError: logger.error.bind(logger), - trackError, -}); +export const { trackError, withCatchAndThrowSnapError } = + createSnapErrorHandling({ + getSnapProvider, + logError: logger.error.bind(logger), + }); diff --git a/packages/solana-wallet-snap/src/core/utils/snap.ts b/packages/solana-wallet-snap/src/core/utils/snap.ts new file mode 100644 index 00000000..fffb00f1 --- /dev/null +++ b/packages/solana-wallet-snap/src/core/utils/snap.ts @@ -0,0 +1,11 @@ +import type { SnapsProvider } from '@metamask/snaps-sdk'; + +/** + * Returns the Snap provider. + * + * @returns The Snap provider. + */ +export function getSnapProvider(): SnapsProvider { + // snap is a global variable provided by the Snap SDK + return snap; +}