diff --git a/jest.config.js b/jest.config.js index 21e678f..f718c68 100644 --- a/jest.config.js +++ b/jest.config.js @@ -22,7 +22,11 @@ module.exports = { collectCoverage: true, // An array of glob patterns indicating a set of files for which coverage information should be collected - collectCoverageFrom: ['./src/**/*.ts', '!./src/__fixtures__/**/*'], + collectCoverageFrom: [ + './src/**/*.ts', + '!./src/__fixtures__/**/*', + '!./src/index.ts', + ], // The directory where Jest should output its coverage files coverageDirectory: 'coverage', @@ -41,10 +45,10 @@ module.exports = { // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { - branches: 94.5, - functions: 85.36, - lines: 96.88, - statements: 96.88, + branches: 94.25, + functions: 94.11, + lines: 97.02, + statements: 97.02, }, }, diff --git a/src/classes.ts b/src/classes.ts index 5dbbdc2..0f9561e 100644 --- a/src/classes.ts +++ b/src/classes.ts @@ -1,5 +1,10 @@ import safeStringify from 'fast-safe-stringify'; -import { Json, JsonRpcError as SerializedJsonRpcError } from '@metamask/utils'; +import { + isPlainObject, + Json, + JsonRpcError as SerializedJsonRpcError, +} from '@metamask/utils'; +import { DataWithOptionalCause, serializeCause } from './utils'; export { SerializedJsonRpcError }; @@ -9,7 +14,7 @@ export { SerializedJsonRpcError }; * * Permits any integer error code. */ -export class JsonRpcError extends Error { +export class JsonRpcError extends Error { public code: number; public data?: T; @@ -40,13 +45,22 @@ export class JsonRpcError extends Error { code: this.code, message: this.message, }; + if (this.data !== undefined) { - serialized.data = this.data; + // `this.data` is not guaranteed to be a plain object, but this simplifies + // the type guard below. We can safely cast it because we know it's a + // JSON-serializable value. + serialized.data = this.data as { [key: string]: Json }; + + if (isPlainObject(this.data)) { + serialized.data.cause = serializeCause(this.data.cause); + } } if (this.stack) { serialized.stack = this.stack; } + return serialized; } @@ -65,7 +79,9 @@ export class JsonRpcError extends Error { * Error subclass implementing Ethereum Provider errors per EIP-1193. * Permits integer error codes in the [ 1000 <= 4999 ] range. */ -export class EthereumProviderError extends JsonRpcError { +export class EthereumProviderError< + T extends DataWithOptionalCause, +> extends JsonRpcError { /** * Create an Ethereum Provider JSON-RPC error. * diff --git a/src/errors.test.ts b/src/errors.test.ts index afee556..63e0ffe 100644 --- a/src/errors.test.ts +++ b/src/errors.test.ts @@ -1,3 +1,4 @@ +import { assert, isPlainObject } from '@metamask/utils'; import { getMessageFromCode, JSON_RPC_SERVER_ERROR_MESSAGE } from './utils'; import { dummyData, @@ -45,7 +46,7 @@ describe('custom provider error options', () => { }); }); -describe('ethError.rpc.server', () => { +describe('rpcErrors.server', () => { it('throws on invalid input', () => { expect(() => { // @ts-expect-error Invalid input @@ -63,6 +64,16 @@ describe('ethError.rpc.server', () => { rpcErrors.server({ code: 1 }); }).toThrow('"code" must be an integer such that: -32099 <= code <= -32005'); }); + + it('returns appropriate value', () => { + const error = rpcErrors.server({ + code: SERVER_ERROR_CODE, + data: Object.assign({}, dummyData), + }); + + expect(error.code <= -32000 && error.code >= -32099).toBe(true); + expect(error.message).toBe(JSON_RPC_SERVER_ERROR_MESSAGE); + }); }); describe('rpcErrors', () => { @@ -85,14 +96,26 @@ describe('rpcErrors', () => { }, ); - it('server returns appropriate value', () => { - const error = rpcErrors.server({ - code: SERVER_ERROR_CODE, - data: Object.assign({}, dummyData), + it('serializes a cause', () => { + const error = rpcErrors.invalidInput({ + data: { + foo: 'bar', + cause: new Error('foo'), + }, }); - expect(error.code <= -32000 && error.code >= -32099).toBe(true); - expect(error.message).toBe(JSON_RPC_SERVER_ERROR_MESSAGE); + const serializedError = error.serialize(); + assert(serializedError.data); + assert(isPlainObject(serializedError.data)); + + expect(serializedError.data.cause).not.toBeInstanceOf(Error); + expect(serializedError.data).toStrictEqual({ + foo: 'bar', + cause: { + message: 'foo', + stack: expect.stringContaining('Error: foo'), + }, + }); }); }); @@ -123,4 +146,26 @@ describe('providerErrors', () => { expect(error.code).toBe(CUSTOM_ERROR_CODE); expect(error.message).toBe(CUSTOM_ERROR_MESSAGE); }); + + it('serializes a cause', () => { + const error = providerErrors.unauthorized({ + data: { + foo: 'bar', + cause: new Error('foo'), + }, + }); + + const serializedError = error.serialize(); + assert(serializedError.data); + assert(isPlainObject(serializedError.data)); + + expect(serializedError.data.cause).not.toBeInstanceOf(Error); + expect(serializedError.data).toStrictEqual({ + foo: 'bar', + cause: { + message: 'foo', + stack: expect.stringContaining('Error: foo'), + }, + }); + }); }); diff --git a/src/errors.ts b/src/errors.ts index 2046e06..a1880e6 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1,20 +1,21 @@ -import { Json } from '@metamask/utils'; import { JsonRpcError, EthereumProviderError } from './classes'; -import { getMessageFromCode } from './utils'; +import { DataWithOptionalCause, getMessageFromCode } from './utils'; import { errorCodes } from './error-constants'; -type EthereumErrorOptions = { +type EthereumErrorOptions = { message?: string; data?: T; }; -type ServerErrorOptions = { +type ServerErrorOptions = { code: number; } & EthereumErrorOptions; -type CustomErrorArg = ServerErrorOptions; +type CustomErrorArg = ServerErrorOptions; -type JsonRpcErrorsArg = EthereumErrorOptions | string; +type JsonRpcErrorsArg = + | EthereumErrorOptions + | string; export const rpcErrors = { /** @@ -23,7 +24,7 @@ export const rpcErrors = { * @param arg - The error message or options bag. * @returns An instance of the {@link JsonRpcError} class. */ - parse: (arg?: JsonRpcErrorsArg) => + parse: (arg?: JsonRpcErrorsArg) => getJsonRpcError(errorCodes.rpc.parse, arg), /** @@ -32,8 +33,9 @@ export const rpcErrors = { * @param arg - The error message or options bag. * @returns An instance of the {@link JsonRpcError} class. */ - invalidRequest: (arg?: JsonRpcErrorsArg) => - getJsonRpcError(errorCodes.rpc.invalidRequest, arg), + invalidRequest: ( + arg?: JsonRpcErrorsArg, + ) => getJsonRpcError(errorCodes.rpc.invalidRequest, arg), /** * Get a JSON RPC 2.0 Invalid Params (-32602) error. @@ -41,7 +43,7 @@ export const rpcErrors = { * @param arg - The error message or options bag. * @returns An instance of the {@link JsonRpcError} class. */ - invalidParams: (arg?: JsonRpcErrorsArg) => + invalidParams: (arg?: JsonRpcErrorsArg) => getJsonRpcError(errorCodes.rpc.invalidParams, arg), /** @@ -50,8 +52,9 @@ export const rpcErrors = { * @param arg - The error message or options bag. * @returns An instance of the {@link JsonRpcError} class. */ - methodNotFound: (arg?: JsonRpcErrorsArg) => - getJsonRpcError(errorCodes.rpc.methodNotFound, arg), + methodNotFound: ( + arg?: JsonRpcErrorsArg, + ) => getJsonRpcError(errorCodes.rpc.methodNotFound, arg), /** * Get a JSON RPC 2.0 Internal (-32603) error. @@ -59,7 +62,7 @@ export const rpcErrors = { * @param arg - The error message or options bag. * @returns An instance of the {@link JsonRpcError} class. */ - internal: (arg?: JsonRpcErrorsArg) => + internal: (arg?: JsonRpcErrorsArg) => getJsonRpcError(errorCodes.rpc.internal, arg), /** @@ -70,7 +73,7 @@ export const rpcErrors = { * @param opts - The error options bag. * @returns An instance of the {@link JsonRpcError} class. */ - server: (opts: ServerErrorOptions) => { + server: (opts: ServerErrorOptions) => { if (!opts || typeof opts !== 'object' || Array.isArray(opts)) { throw new Error( 'Ethereum RPC Server errors must provide single object argument.', @@ -91,7 +94,7 @@ export const rpcErrors = { * @param arg - The error message or options bag. * @returns An instance of the {@link JsonRpcError} class. */ - invalidInput: (arg?: JsonRpcErrorsArg) => + invalidInput: (arg?: JsonRpcErrorsArg) => getJsonRpcError(errorCodes.rpc.invalidInput, arg), /** @@ -100,8 +103,9 @@ export const rpcErrors = { * @param arg - The error message or options bag. * @returns An instance of the {@link JsonRpcError} class. */ - resourceNotFound: (arg?: JsonRpcErrorsArg) => - getJsonRpcError(errorCodes.rpc.resourceNotFound, arg), + resourceNotFound: ( + arg?: JsonRpcErrorsArg, + ) => getJsonRpcError(errorCodes.rpc.resourceNotFound, arg), /** * Get an Ethereum JSON RPC Resource Unavailable (-32002) error. @@ -109,8 +113,9 @@ export const rpcErrors = { * @param arg - The error message or options bag. * @returns An instance of the {@link JsonRpcError} class. */ - resourceUnavailable: (arg?: JsonRpcErrorsArg) => - getJsonRpcError(errorCodes.rpc.resourceUnavailable, arg), + resourceUnavailable: ( + arg?: JsonRpcErrorsArg, + ) => getJsonRpcError(errorCodes.rpc.resourceUnavailable, arg), /** * Get an Ethereum JSON RPC Transaction Rejected (-32003) error. @@ -118,8 +123,9 @@ export const rpcErrors = { * @param arg - The error message or options bag. * @returns An instance of the {@link JsonRpcError} class. */ - transactionRejected: (arg?: JsonRpcErrorsArg) => - getJsonRpcError(errorCodes.rpc.transactionRejected, arg), + transactionRejected: ( + arg?: JsonRpcErrorsArg, + ) => getJsonRpcError(errorCodes.rpc.transactionRejected, arg), /** * Get an Ethereum JSON RPC Method Not Supported (-32004) error. @@ -127,8 +133,9 @@ export const rpcErrors = { * @param arg - The error message or options bag. * @returns An instance of the {@link JsonRpcError} class. */ - methodNotSupported: (arg?: JsonRpcErrorsArg) => - getJsonRpcError(errorCodes.rpc.methodNotSupported, arg), + methodNotSupported: ( + arg?: JsonRpcErrorsArg, + ) => getJsonRpcError(errorCodes.rpc.methodNotSupported, arg), /** * Get an Ethereum JSON RPC Limit Exceeded (-32005) error. @@ -136,7 +143,7 @@ export const rpcErrors = { * @param arg - The error message or options bag. * @returns An instance of the {@link JsonRpcError} class. */ - limitExceeded: (arg?: JsonRpcErrorsArg) => + limitExceeded: (arg?: JsonRpcErrorsArg) => getJsonRpcError(errorCodes.rpc.limitExceeded, arg), }; @@ -147,7 +154,9 @@ export const providerErrors = { * @param arg - The error message or options bag. * @returns An instance of the {@link EthereumProviderError} class. */ - userRejectedRequest: (arg?: JsonRpcErrorsArg) => { + userRejectedRequest: ( + arg?: JsonRpcErrorsArg, + ) => { return getEthProviderError(errorCodes.provider.userRejectedRequest, arg); }, @@ -157,7 +166,9 @@ export const providerErrors = { * @param arg - The error message or options bag. * @returns An instance of the {@link EthereumProviderError} class. */ - unauthorized: (arg?: JsonRpcErrorsArg) => { + unauthorized: ( + arg?: JsonRpcErrorsArg, + ) => { return getEthProviderError(errorCodes.provider.unauthorized, arg); }, @@ -167,7 +178,9 @@ export const providerErrors = { * @param arg - The error message or options bag. * @returns An instance of the {@link EthereumProviderError} class. */ - unsupportedMethod: (arg?: JsonRpcErrorsArg) => { + unsupportedMethod: ( + arg?: JsonRpcErrorsArg, + ) => { return getEthProviderError(errorCodes.provider.unsupportedMethod, arg); }, @@ -177,7 +190,9 @@ export const providerErrors = { * @param arg - The error message or options bag. * @returns An instance of the {@link EthereumProviderError} class. */ - disconnected: (arg?: JsonRpcErrorsArg) => { + disconnected: ( + arg?: JsonRpcErrorsArg, + ) => { return getEthProviderError(errorCodes.provider.disconnected, arg); }, @@ -187,7 +202,9 @@ export const providerErrors = { * @param arg - The error message or options bag. * @returns An instance of the {@link EthereumProviderError} class. */ - chainDisconnected: (arg?: JsonRpcErrorsArg) => { + chainDisconnected: ( + arg?: JsonRpcErrorsArg, + ) => { return getEthProviderError(errorCodes.provider.chainDisconnected, arg); }, @@ -197,7 +214,7 @@ export const providerErrors = { * @param opts - The error options bag. * @returns An instance of the {@link EthereumProviderError} class. */ - custom: (opts: CustomErrorArg) => { + custom: (opts: CustomErrorArg) => { if (!opts || typeof opts !== 'object' || Array.isArray(opts)) { throw new Error( 'Ethereum Provider custom errors must provide single object argument.', @@ -220,7 +237,7 @@ export const providerErrors = { * @param arg - The error message or options bag. * @returns An instance of the {@link JsonRpcError} class. */ -function getJsonRpcError( +function getJsonRpcError( code: number, arg?: JsonRpcErrorsArg, ): JsonRpcError { @@ -235,7 +252,7 @@ function getJsonRpcError( * @param arg - The error message or options bag. * @returns An instance of the {@link EthereumProviderError} class. */ -function getEthProviderError( +function getEthProviderError( code: number, arg?: JsonRpcErrorsArg, ): EthereumProviderError { @@ -253,7 +270,7 @@ function getEthProviderError( * @param arg - The error message or options bag. * @returns A tuple containing the error message and optional data. */ -function parseOpts( +function parseOpts( arg?: JsonRpcErrorsArg, ): [message?: string | undefined, data?: T | undefined] { if (arg) { diff --git a/src/index.ts b/src/index.ts index 4d0e4cd..9a28c4a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,14 +1,5 @@ -import { JsonRpcError, EthereumProviderError } from './classes'; -import { serializeError, getMessageFromCode } from './utils'; -import { rpcErrors, providerErrors } from './errors'; -import { errorCodes } from './error-constants'; - -export { - errorCodes, - rpcErrors, - providerErrors, - JsonRpcError, - EthereumProviderError, - serializeError, - getMessageFromCode, -}; +export { JsonRpcError, EthereumProviderError } from './classes'; +export { serializeCause, serializeError, getMessageFromCode } from './utils'; +export type { DataWithOptionalCause } from './utils'; +export { rpcErrors, providerErrors } from './errors'; +export { errorCodes } from './error-constants'; diff --git a/src/utils.ts b/src/utils.ts index 35340b1..69abc87 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -9,6 +9,20 @@ import { } from '@metamask/utils'; import { errorCodes, errorValues } from './error-constants'; +/** + * A data object, that must be either: + * + * - A JSON-serializable object. + * - An object with a `cause` property that is an `Error` instance, and any + * other properties that are JSON-serializable. + */ +export type DataWithOptionalCause = + | Json + | { + [key: string]: Json | Error; + cause: Error; + }; + const FALLBACK_ERROR_CODE = errorCodes.rpc.internal; const FALLBACK_MESSAGE = 'Unspecified error message. This is a bug, please report it.'; @@ -143,7 +157,7 @@ function isJsonRpcServerError(code: number): boolean { * @param error - The unknown error. * @returns A JSON-serializable object containing as much information about the original error as possible. */ -function serializeCause(error: unknown): Json { +export function serializeCause(error: unknown): Json { if (Array.isArray(error)) { return error.map((entry) => { if (isValidJson(entry)) {