From b4310778aa0f15e612d9dde3881953724ddfaa07 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Mon, 13 Jan 2025 15:51:18 +1100 Subject: [PATCH] feat: add `RpcResponse.parseError` and `Provider.parseError` --- .changeset/olive-games-retire.md | 5 + src/core/Provider.ts | 108 +++++++++++- src/core/RpcResponse.ts | 157 ++++++++++++++--- src/core/_test/Provider.test.ts | 261 +++++++++++++++++++++++++++++ src/core/_test/RpcResponse.test.ts | 149 ++++++++++++++-- 5 files changed, 633 insertions(+), 47 deletions(-) create mode 100644 .changeset/olive-games-retire.md diff --git a/.changeset/olive-games-retire.md b/.changeset/olive-games-retire.md new file mode 100644 index 00000000..ed5ed5f1 --- /dev/null +++ b/.changeset/olive-games-retire.md @@ -0,0 +1,5 @@ +--- +"ox": patch +--- + +Added `RpcResponse.parseError` and `Provider.parseError`. diff --git a/src/core/Provider.ts b/src/core/Provider.ts index 012b860c..ac3dec2d 100644 --- a/src/core/Provider.ts +++ b/src/core/Provider.ts @@ -4,7 +4,7 @@ import * as Errors from './Errors.js' import * as RpcResponse from './RpcResponse.js' import type * as RpcSchema from './RpcSchema.js' import type * as RpcSchema_internal from './internal/rpcSchema.js' -import type { Compute } from './internal/types.js' +import type { Compute, IsNarrowable, IsNever } from './internal/types.js' /** Options for a {@link ox#Provider.Provider}. */ export type Options = { @@ -92,6 +92,7 @@ export type EventMap = { /** The user rejected the request. */ export class UserRejectedRequestError extends ProviderRpcError { static readonly code = 4001 + override readonly code = 4001 override readonly name = 'Provider.UserRejectedRequestError' constructor({ @@ -104,6 +105,7 @@ export class UserRejectedRequestError extends ProviderRpcError { /** The requested method and/or account has not been authorized by the user. */ export class UnauthorizedError extends ProviderRpcError { static readonly code = 4100 + override readonly code = 4100 override readonly name = 'Provider.UnauthorizedError' constructor({ @@ -116,6 +118,7 @@ export class UnauthorizedError extends ProviderRpcError { /** The provider does not support the requested method. */ export class UnsupportedMethodError extends ProviderRpcError { static readonly code = 4200 + override readonly code = 4200 override readonly name = 'Provider.UnsupportedMethodError' constructor({ @@ -128,6 +131,7 @@ export class UnsupportedMethodError extends ProviderRpcError { /** The provider is disconnected from all chains. */ export class DisconnectedError extends ProviderRpcError { static readonly code = 4900 + override readonly code = 4900 override readonly name = 'Provider.DisconnectedError' constructor({ @@ -140,6 +144,7 @@ export class DisconnectedError extends ProviderRpcError { /** The provider is not connected to the requested chain. */ export class ChainDisconnectedError extends ProviderRpcError { static readonly code = 4901 + override readonly code = 4901 override readonly name = 'Provider.ChainDisconnectedError' constructor({ @@ -385,14 +390,18 @@ export function from(provider: any, options: Options = {}): Provider { } : {}), async request(args) { - const result = await provider.request(args) - if ( - result && - typeof result === 'object' && - 'jsonrpc' in (result as { jsonrpc?: unknown }) - ) - return RpcResponse.parse(result) as never - return result + try { + const result = await provider.request(args) + if ( + result && + typeof result === 'object' && + 'jsonrpc' in (result as { jsonrpc?: unknown }) + ) + return RpcResponse.parse(result) as never + return result + } catch (error) { + throw parseError(error) + } }, } } @@ -401,6 +410,87 @@ export declare namespace from { type ErrorType = IsUndefinedError | Errors.GlobalErrorType } +/** + * Parses an error object into an error instance. + * + * @example + * ```ts twoslash + * import { Provider } from 'ox' + * + * const error = Provider.parseError({ code: 4200, message: 'foo' }) + * + * error + * // ^? + * + * ``` + * + * @param errorObject - The error object to parse. + * @returns An error instance. + */ +export function parseError< + const errorObject extends RpcResponse.ErrorObject | unknown, +>( + errorObject: errorObject | RpcResponse.ErrorObject, +): parseError.ReturnType { + const errorObject_ = errorObject as RpcResponse.ErrorObject + const error = RpcResponse.parseError(errorObject_) + if (error instanceof RpcResponse.BaseError) { + if (error.code === DisconnectedError.code) + return new DisconnectedError(errorObject_) as never + if (error.code === ChainDisconnectedError.code) + return new ChainDisconnectedError(errorObject_) as never + if (error.code === UserRejectedRequestError.code) + return new UserRejectedRequestError(errorObject_) as never + if (error.code === UnauthorizedError.code) + return new UnauthorizedError(errorObject_) as never + if (error.code === UnsupportedMethodError.code) + return new UnsupportedMethodError(errorObject_) as never + } + return error as never +} + +export declare namespace parseError { + type ReturnType< + errorObject extends RpcResponse.ErrorObject | unknown, + // + error = errorObject extends RpcResponse.ErrorObject + ? + | (errorObject['code'] extends DisconnectedError['code'] + ? DisconnectedError + : never) + | (IsNarrowable extends false + ? DisconnectedError + : never) + | (errorObject['code'] extends ChainDisconnectedError['code'] + ? ChainDisconnectedError + : never) + | (IsNarrowable extends false + ? ChainDisconnectedError + : never) + | (errorObject['code'] extends UserRejectedRequestError['code'] + ? UserRejectedRequestError + : never) + | (IsNarrowable extends false + ? UserRejectedRequestError + : never) + | (errorObject['code'] extends UnauthorizedError['code'] + ? UnauthorizedError + : never) + | (IsNarrowable extends false + ? UnauthorizedError + : never) + | (errorObject['code'] extends UnsupportedMethodError['code'] + ? UnsupportedMethodError + : never) + | (IsNarrowable extends false + ? UnsupportedMethodError + : never) + : RpcResponse.parseError.ReturnType, + > = IsNever extends true + ? RpcResponse.parseError.ReturnType + : error +} + /** Thrown when the provider is undefined. */ export class IsUndefinedError extends Errors.BaseError { override readonly name = 'Provider.IsUndefinedError' diff --git a/src/core/RpcResponse.ts b/src/core/RpcResponse.ts index 8d8a73ae..ad64f548 100644 --- a/src/core/RpcResponse.ts +++ b/src/core/RpcResponse.ts @@ -2,6 +2,7 @@ import type { Errors, RpcRequest } from '../index.js' import type { Compute, IsNarrowable, + IsNever, OneOf, UnionPartialBy, } from './internal/types.js' @@ -215,28 +216,7 @@ export function parse< const { raw = false } = options const response_ = response as RpcResponse if (raw) return response as never - if (response_.error) { - const { code } = response_.error - const JsonRpcError = (() => { - if (code === InternalError.code) return InternalError - if (code === InvalidInputError.code) return InvalidInputError - if (code === InvalidParamsError.code) return InvalidParamsError - if (code === InvalidRequestError.code) return InvalidRequestError - if (code === LimitExceededError.code) return LimitExceededError - if (code === MethodNotFoundError.code) return MethodNotFoundError - if (code === MethodNotSupportedError.code) return MethodNotSupportedError - if (code === ParseError.code) return ParseError - if (code === ResourceNotFoundError.code) return ResourceNotFoundError - if (code === ResourceUnavailableError.code) - return ResourceUnavailableError - if (code === TransactionRejectedError.code) - return TransactionRejectedError - if (code === VersionNotSupportedError.code) - return VersionNotSupportedError - return BaseError - })() - throw new JsonRpcError(response_.error) - } + if (response_.error) throw parseError(response_.error) return response_.result as never } @@ -283,6 +263,139 @@ export declare namespace parse { | Errors.GlobalErrorType } +/** + * Parses a JSON-RPC error object into an error instance. + * + * @example + * ```ts twoslash + * import { RpcResponse } from 'ox' + * + * const error = RpcResponse.parseError({ code: -32000, message: 'unsupported method' }) + * + * error + * // ^? + * + * ``` + * + * @param errorObject - JSON-RPC error object. + * @returns Error instance. + */ +export function parseError( + errorObject: errorObject | ErrorObject, +): parseError.ReturnType { + const errorObject_ = errorObject as ErrorObject + const { code } = errorObject_ + if (code === InternalError.code) + return new InternalError(errorObject_) as never + if (code === InvalidInputError.code) + return new InvalidInputError(errorObject_) as never + if (code === InvalidParamsError.code) + return new InvalidParamsError(errorObject_) as never + if (code === InvalidRequestError.code) + return new InvalidRequestError(errorObject_) as never + if (code === LimitExceededError.code) + return new LimitExceededError(errorObject_) as never + if (code === MethodNotFoundError.code) + return new MethodNotFoundError(errorObject_) as never + if (code === MethodNotSupportedError.code) + return new MethodNotSupportedError(errorObject_) as never + if (code === ParseError.code) return new ParseError(errorObject_) as never + if (code === ResourceNotFoundError.code) + return new ResourceNotFoundError(errorObject_) as never + if (code === ResourceUnavailableError.code) + return new ResourceUnavailableError(errorObject_) as never + if (code === TransactionRejectedError.code) + return new TransactionRejectedError(errorObject_) as never + if (code === VersionNotSupportedError.code) + return new VersionNotSupportedError(errorObject_) as never + return new BaseError(errorObject_) as never +} + +export declare namespace parseError { + type ReturnType< + errorObject extends ErrorObject | unknown, + // + error = errorObject extends ErrorObject + ? + | (errorObject['code'] extends InternalError['code'] + ? InternalError + : never) + | (IsNarrowable extends false + ? InternalError + : never) + | (errorObject['code'] extends InvalidInputError['code'] + ? InvalidInputError + : never) + | (IsNarrowable extends false + ? InvalidInputError + : never) + | (errorObject['code'] extends ResourceNotFoundError['code'] + ? ResourceNotFoundError + : never) + | (IsNarrowable extends false + ? ResourceNotFoundError + : never) + | (errorObject['code'] extends ResourceUnavailableError['code'] + ? ResourceUnavailableError + : never) + | (IsNarrowable extends false + ? ResourceUnavailableError + : never) + | (errorObject['code'] extends TransactionRejectedError['code'] + ? TransactionRejectedError + : never) + | (IsNarrowable extends false + ? TransactionRejectedError + : never) + | (errorObject['code'] extends ParseError['code'] + ? ParseError + : never) + | (IsNarrowable extends false + ? ParseError + : never) + | (errorObject['code'] extends MethodNotSupportedError['code'] + ? MethodNotSupportedError + : never) + | (IsNarrowable extends false + ? MethodNotSupportedError + : never) + | (errorObject['code'] extends LimitExceededError['code'] + ? LimitExceededError + : never) + | (IsNarrowable extends false + ? LimitExceededError + : never) + | (errorObject['code'] extends VersionNotSupportedError['code'] + ? VersionNotSupportedError + : never) + | (IsNarrowable extends false + ? VersionNotSupportedError + : never) + | (errorObject['code'] extends InvalidRequestError['code'] + ? InvalidRequestError + : never) + | (IsNarrowable extends false + ? InvalidRequestError + : never) + | (errorObject['code'] extends MethodNotFoundError['code'] + ? MethodNotFoundError + : never) + | (IsNarrowable extends false + ? MethodNotFoundError + : never) + | (errorObject['code'] extends InvalidParamsError['code'] + ? InvalidParamsError + : never) + | (IsNarrowable extends false + ? InvalidParamsError + : never) + | (IsNarrowable extends false + ? BaseError + : never) + : parseError.ReturnType, + > = IsNever extends true ? BaseError : error +} + export type BaseErrorType = BaseError & { name: 'BaseError' } /** Thrown when a JSON-RPC error has occurred. */ diff --git a/src/core/_test/Provider.test.ts b/src/core/_test/Provider.test.ts index 0d165596..09bd7460 100644 --- a/src/core/_test/Provider.test.ts +++ b/src/core/_test/Provider.test.ts @@ -107,6 +107,266 @@ describe('Provider.from', () => { `) }) + test('behavior: UnauthorizedError', async () => { + const provider = Provider.from({ + async request(_) { + throw new Provider.UnauthorizedError() + }, + }) + + await expect(() => + provider.request({ + method: 'eth_blockNumber', + }), + ).rejects.toThrowErrorMatchingInlineSnapshot( + '[Provider.UnauthorizedError: The requested method and/or account has not been authorized by the user.]', + ) + }) + + test('behavior: UnauthorizedError (raw)', async () => { + const provider = Provider.from({ + async request(_) { + return { + jsonrpc: '2.0', + id: 0, + error: { + code: Provider.UnauthorizedError.code, + message: 'foo', + }, + } + }, + }) + + await expect(() => + provider.request({ + method: 'eth_blockNumber', + }), + ).rejects.toThrowErrorMatchingInlineSnapshot( + '[Provider.UnauthorizedError: foo]', + ) + }) + + test('behavior: UserRejectedRequestError', async () => { + const provider = Provider.from({ + async request(_) { + throw new Provider.UserRejectedRequestError() + }, + }) + + await expect(() => + provider.request({ + method: 'eth_blockNumber', + }), + ).rejects.toThrowErrorMatchingInlineSnapshot( + '[Provider.UserRejectedRequestError: The user rejected the request.]', + ) + }) + + test('behavior: UserRejectedRequestError (raw)', async () => { + const provider = Provider.from({ + async request(_) { + return { + jsonrpc: '2.0', + id: 0, + error: { + code: Provider.UserRejectedRequestError.code, + message: 'foo', + }, + } + }, + }) + + await expect(() => + provider.request({ + method: 'eth_blockNumber', + }), + ).rejects.toThrowErrorMatchingInlineSnapshot( + '[Provider.UserRejectedRequestError: foo]', + ) + }) + + test('behavior: UnsupportedMethodError', async () => { + const provider = Provider.from({ + async request(_) { + throw new Provider.UnsupportedMethodError() + }, + }) + + await expect(() => + provider.request({ + method: 'eth_blockNumber', + }), + ).rejects.toThrowErrorMatchingInlineSnapshot( + '[Provider.UnsupportedMethodError: The provider does not support the requested method.]', + ) + }) + + test('behavior: UnsupportedMethodError (raw)', async () => { + const provider = Provider.from({ + async request(_) { + return { + jsonrpc: '2.0', + id: 0, + error: { + code: Provider.UnsupportedMethodError.code, + message: 'foo', + }, + } + }, + }) + + await expect(() => + provider.request({ + method: 'eth_blockNumber', + }), + ).rejects.toThrowErrorMatchingInlineSnapshot( + '[Provider.UnsupportedMethodError: foo]', + ) + }) + + test('behavior: DisconnectedError', async () => { + const provider = Provider.from({ + async request(_) { + throw new Provider.DisconnectedError() + }, + }) + + await expect(() => + provider.request({ + method: 'eth_blockNumber', + }), + ).rejects.toThrowErrorMatchingInlineSnapshot( + '[Provider.DisconnectedError: The provider is disconnected from all chains.]', + ) + }) + + test('behavior: DisconnectedError (raw)', async () => { + const provider = Provider.from({ + async request(_) { + return { + jsonrpc: '2.0', + id: 0, + error: { + code: Provider.DisconnectedError.code, + message: 'foo', + }, + } + }, + }) + + await expect(() => + provider.request({ + method: 'eth_blockNumber', + }), + ).rejects.toThrowErrorMatchingInlineSnapshot( + '[Provider.DisconnectedError: foo]', + ) + }) + + test('behavior: ChainDisconnectedError', async () => { + const provider = Provider.from({ + async request(_) { + throw new Provider.ChainDisconnectedError() + }, + }) + + await expect(() => + provider.request({ + method: 'eth_blockNumber', + }), + ).rejects.toThrowErrorMatchingInlineSnapshot( + '[Provider.ChainDisconnectedError: The provider is not connected to the requested chain.]', + ) + }) + + test('behavior: ChainDisconnectedError (raw)', async () => { + const provider = Provider.from({ + async request(_) { + return { + jsonrpc: '2.0', + id: 0, + error: { + code: Provider.ChainDisconnectedError.code, + message: 'foo', + }, + } + }, + }) + + await expect(() => + provider.request({ + method: 'eth_blockNumber', + }), + ).rejects.toThrowErrorMatchingInlineSnapshot( + '[Provider.ChainDisconnectedError: foo]', + ) + }) + + test('behavior: BaseError', async () => { + const provider = Provider.from({ + async request(_) { + throw new Error('foo') + }, + }) + + await expect(() => + provider.request({ + method: 'eth_blockNumber', + }), + ).rejects.toThrowErrorMatchingInlineSnapshot('[RpcResponse.BaseError: foo]') + }) + + test('behavior: BaseError (raw)', async () => { + const provider = Provider.from({ + async request(_) { + return { + jsonrpc: '2.0', + id: 0, + error: { + code: 1000, + message: 'foo', + }, + } + }, + }) + + await expect(() => + provider.request({ + method: 'eth_blockNumber', + }), + ).rejects.toThrowErrorMatchingInlineSnapshot('[RpcResponse.BaseError: foo]') + }) + + test('behavior: network rpc error', async () => { + const store = RpcRequest.createStore() + + const provider = Provider.from({ + async request(args) { + return await fetch(anvilMainnet.rpcUrl, { + body: JSON.stringify(store.prepare(args as never)), + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }).then((res) => res.json()) + }, + }) + + await expect(() => + provider.request({ + method: 'eth_sendTransaction', + params: [ + { + from: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', + to: '0x0000000000000000000000000000000000000000', + }, + ], + }), + ).rejects.toThrowErrorMatchingInlineSnapshot( + '[RpcResponse.InvalidParamsError: Invalid method parameters.]', + ) + }) + test('error: undefined', () => { expect(() => Provider.from(undefined)).toThrowErrorMatchingInlineSnapshot( '[Provider.IsUndefinedError: `provider` is undefined.]', @@ -195,6 +455,7 @@ test('exports', () => { "ChainDisconnectedError", "createEmitter", "from", + "parseError", "IsUndefinedError", ] `) diff --git a/src/core/_test/RpcResponse.test.ts b/src/core/_test/RpcResponse.test.ts index d8767941..f09cb937 100644 --- a/src/core/_test/RpcResponse.test.ts +++ b/src/core/_test/RpcResponse.test.ts @@ -45,9 +45,8 @@ describe('parse', () => { method: 'eth_estimateGas', params: [ { - from: '0x0000000000000000000000000000000000000000', + from: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', to: '0x0000000000000000000000000000000000000000', - data: '0xdeadbeef', }, ], id: 0, @@ -64,7 +63,7 @@ describe('parse', () => { const gas = RpcResponse.parse(raw) assertType(gas) - expect(gas).toMatchInlineSnapshot(`"0x5248"`) + expect(gas).toMatchInlineSnapshot(`"0x5208"`) }) test('error', async () => { @@ -72,9 +71,8 @@ describe('parse', () => { method: 'eth_sendTransaction', params: [ { - from: '0x0000000000000000000000000000000000000000', + from: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', to: '0x0000000000000000000000000000000000000000', - data: '0xdeadbeef', }, ], id: 0, @@ -98,9 +96,8 @@ describe('parse', () => { method: 'eth_estimateGas', params: [ { - from: '0x0000000000000000000000000000000000000000', + from: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', to: '0x0000000000000000000000000000000000000000', - data: '0xdeadbeef', }, ], id: 0, @@ -119,7 +116,7 @@ describe('parse', () => { }) assertType(gas) - expect(gas).toMatchInlineSnapshot(`"0x5248"`) + expect(gas).toMatchInlineSnapshot(`"0x5208"`) }) test('options: safe', async () => { @@ -127,9 +124,8 @@ describe('parse', () => { method: 'eth_estimateGas', params: [ { - from: '0x0000000000000000000000000000000000000000', + from: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', to: '0x0000000000000000000000000000000000000000', - data: '0xdeadbeef', }, ], id: 0, @@ -151,12 +147,12 @@ describe('parse', () => { assertType>(response) expect(response).toMatchInlineSnapshot(` - { - "id": 0, - "jsonrpc": "2.0", - "result": "0x5248", - } - `) + { + "id": 0, + "jsonrpc": "2.0", + "result": "0x5208", + } + `) } { @@ -361,6 +357,126 @@ describe('parse', () => { }) }) +describe('parseError', () => { + test('InternalError', () => { + const error = RpcResponse.parseError({ + code: -32603, + message: 'foo', + }) + expect(error).toMatchInlineSnapshot('[RpcResponse.InternalErrorError: foo]') + }) + + test('InvalidInputError', () => { + const error = RpcResponse.parseError({ + code: -32000, + message: 'foo', + }) + expect(error).toMatchInlineSnapshot('[RpcResponse.InvalidInputError: foo]') + }) + + test('InvalidParamsError', () => { + const error = RpcResponse.parseError({ + code: -32602, + message: 'foo', + }) + expect(error).toMatchInlineSnapshot('[RpcResponse.InvalidParamsError: foo]') + }) + + test('InvalidRequestError', () => { + const error = RpcResponse.parseError({ + code: -32600, + message: 'foo', + }) + expect(error).toMatchInlineSnapshot( + '[RpcResponse.InvalidRequestError: foo]', + ) + }) + + test('LimitExceededError', () => { + const error = RpcResponse.parseError({ + code: -32005, + message: 'foo', + }) + expect(error).toMatchInlineSnapshot('[RpcResponse.LimitExceededError: foo]') + }) + + test('MethodNotFoundError', () => { + const error = RpcResponse.parseError({ + code: -32601, + message: 'foo', + }) + expect(error).toMatchInlineSnapshot( + '[RpcResponse.MethodNotFoundError: foo]', + ) + }) + + test('MethodNotSupportedError', () => { + const error = RpcResponse.parseError({ + code: -32004, + message: 'foo', + }) + expect(error).toMatchInlineSnapshot( + '[RpcResponse.MethodNotSupportedError: foo]', + ) + }) + + test('ParseError', () => { + const error = RpcResponse.parseError({ + code: -32700, + message: 'foo', + }) + expect(error).toMatchInlineSnapshot('[RpcResponse.ParseError: foo]') + }) + + test('ResourceNotFoundError', () => { + const error = RpcResponse.parseError({ + code: -32001, + message: 'foo', + }) + expect(error).toMatchInlineSnapshot( + '[RpcResponse.ResourceNotFoundError: foo]', + ) + }) + + test('ResourceUnavailableError', () => { + const error = RpcResponse.parseError({ + code: -32002, + message: 'foo', + }) + expect(error).toMatchInlineSnapshot( + '[RpcResponse.ResourceUnavailableError: foo]', + ) + }) + + test('TransactionRejectedError', () => { + const error = RpcResponse.parseError({ + code: -32003, + message: 'foo', + }) + expect(error).toMatchInlineSnapshot( + '[RpcResponse.TransactionRejectedError: foo]', + ) + }) + + test('VersionNotSupportedError', () => { + const error = RpcResponse.parseError({ + code: -32006, + message: 'foo', + }) + expect(error).toMatchInlineSnapshot( + '[RpcResponse.VersionNotSupportedError: foo]', + ) + }) + + test('BaseError', () => { + const error = RpcResponse.parseError({ + code: -69420, + message: 'foo', + }) + expect(error).toMatchInlineSnapshot('[RpcResponse.BaseError: foo]') + }) +}) + test('InvalidInputError', () => { expect(new RpcResponse.InvalidInputError()).toMatchInlineSnapshot( '[RpcResponse.InvalidInputError: Missing or invalid parameters.]', @@ -530,6 +646,7 @@ test('exports', () => { [ "from", "parse", + "parseError", "BaseError", "InvalidInputError", "ResourceNotFoundError",