Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 0 additions & 8 deletions eslint-suppressions.json
Original file line number Diff line number Diff line change
Expand Up @@ -860,14 +860,6 @@
},
"@typescript-eslint/no-shadow": {
"count": 1
},
"@typescript-eslint/no-unused-vars": {
"count": 2
}
},
"packages/solana-wallet-snap/src/core/utils/errors.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"packages/solana-wallet-snap/src/core/utils/formatCrypto.test.ts": {
Expand Down
4 changes: 4 additions & 0 deletions packages/snap-networks-utils/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added

- Add helpers `serialize`, `deserialize`, and `Serializable` for round-tripping `BigNumber`, `bigint`, `Uint8Array`, and `undefined` through snap state ([#197](https://github.com/MetaMask/internal-snaps/pull/197))
- Add shared snap error utilities to the main package entry point ([#241](https://github.com/MetaMask/internal-snaps/pull/241))
- `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 `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))
Expand Down
5 changes: 5 additions & 0 deletions packages/snap-networks-utils/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ module.exports = merge(baseConfig, {
// The display name when running multiple projects
displayName,

coveragePathIgnorePatterns: [
...(baseConfig.coveragePathIgnorePatterns ?? []),
'.*/__mocks__/',
],

// An object that configures minimum threshold enforcement for coverage results
coverageThreshold: {
global: {
Expand Down
12 changes: 12 additions & 0 deletions packages/snap-networks-utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,15 @@ export {
} from './utils/originPermissions/createOriginPermissions';
export type { CreateOriginPermissionsParams } from './utils/originPermissions/createOriginPermissions';
export { validateOrigin } from './utils/originPermissions/validateOrigin';
export {
createWithCatchAndThrowSnapError,
isSnapRpcError,
normalizeError,
} from './utils/errors';
export type {
CreateWithCatchAndThrowSnapErrorOptions,
LogErrorFn,
NormalizeErrorFn,
SnapRpcError,
TrackErrorFn,
} from './utils/errors';
219 changes: 219 additions & 0 deletions packages/snap-networks-utils/src/utils/errors/errors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
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';

type SetupTestResult = {
trackError: jest.Mock;
withCatchAndThrowSnapError: ReturnType<
typeof createWithCatchAndThrowSnapError
>;
createBoundWithCatchAndThrowSnapError: (
options?: Omit<CreateWithCatchAndThrowSnapErrorOptions, 'logError'>,
) => ReturnType<typeof createWithCatchAndThrowSnapError>;
};

const setupTest = (): SetupTestResult => {
jest.clearAllMocks();

const trackError = jest.fn();
const withCatchAndThrowSnapError = createWithCatchAndThrowSnapError({
logError: mockLogger.error.bind(mockLogger),
trackError,
});

return {
trackError,
withCatchAndThrowSnapError,
createBoundWithCatchAndThrowSnapError: (
options: Omit<CreateWithCatchAndThrowSnapErrorOptions, 'logError'> = {
trackError,
},
): ReturnType<typeof createWithCatchAndThrowSnapError> =>
createWithCatchAndThrowSnapError({
...options,
logError: mockLogger.error.bind(mockLogger),
}),
};
};

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 { 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 { 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 { 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 { 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' }),
);
});

it('uses a custom logError when provided', async () => {
const { trackError, withCatchAndThrowSnapError } = setupTest();
const customLogError = jest.fn();
const originalError = new Error('Test error');
const mockFn = jest.fn().mockRejectedValue(originalError);

await expect(
withCatchAndThrowSnapError(mockFn, customLogError),
).rejects.toThrow(SnapError);

expect(customLogError).toHaveBeenCalledTimes(1);
expect(mockLogger.error).not.toHaveBeenCalled();
expect(trackError).toHaveBeenCalledWith(originalError);
});
});
});
90 changes: 90 additions & 0 deletions packages/snap-networks-utils/src/utils/errors/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
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<string | undefined>;

/**
* 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 LogErrorFn = Logger['error'];

export type CreateWithCatchAndThrowSnapErrorOptions = {
logError: LogErrorFn;
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.
* @param options.logError - Logger method used to record the normalized error.
* @param options.trackError - Snap-specific Sentry transport.
* @param options.normalizeErrorFn - Optional error normalizer; defaults to {@link normalizeError}.
* @returns A function that catches errors, tracks them, logs, and rethrows as Snap RPC errors.
*/
export function createWithCatchAndThrowSnapError({
logError,
trackError,
normalizeErrorFn = normalizeError,
}: CreateWithCatchAndThrowSnapErrorOptions): <ResponseT>(
fn: () => Promise<ResponseT>,
logErrorOverride?: LogErrorFn,
) => Promise<ResponseT> {
return <ResponseT>(
fn: () => Promise<ResponseT>,
logErrorOverride?: LogErrorFn,
): Promise<ResponseT> =>
withCatchAndThrowSnapErrorHandler(
logErrorOverride ?? logError,
trackError,
normalizeErrorFn,
fn,
);
}

async function withCatchAndThrowSnapErrorHandler<ResponseT>(
logError: LogErrorFn,
trackError: TrackErrorFn,
normalizeErrorFn: NormalizeErrorFn,
fn: () => Promise<ResponseT>,
): Promise<ResponseT> {
try {
return await fn();
} catch (unknownError) {
await trackError(unknownError);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it may have duplicate track error
when snap it self already track the error from deeper level?


const error = normalizeErrorFn(unknownError);

logError(
{ error },
`[SnapError] ${JSON.stringify(error.toJSON(), null, 2)}`,
);

// eslint-disable-next-line @typescript-eslint/only-throw-error -- Snap RPC errors are the handler boundary surface
throw error;
}
}
9 changes: 9 additions & 0 deletions packages/snap-networks-utils/src/utils/errors/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export { createWithCatchAndThrowSnapError, normalizeError } from './errors';
export { isSnapRpcError } from './snapRpcError';
export type {
CreateWithCatchAndThrowSnapErrorOptions,
LogErrorFn,
NormalizeErrorFn,
TrackErrorFn,
} from './errors';
export type { SnapRpcError } from './snapRpcError';
Loading