diff --git a/v-next/hardhat-errors/src/descriptors.ts b/v-next/hardhat-errors/src/descriptors.ts index 38d9ab44c2..23068c5426 100644 --- a/v-next/hardhat-errors/src/descriptors.ts +++ b/v-next/hardhat-errors/src/descriptors.ts @@ -66,6 +66,7 @@ export const ERROR_CATEGORIES: { }, ARGUMENTS: { min: 500, max: 599, websiteTitle: "Arguments related errors" }, BUILTIN_TASKS: { min: 600, max: 699, websiteTitle: "Built-in tasks errors" }, + NETWORK: { min: 700, max: 799, websiteTitle: "Network errors" }, }; export const ERRORS = { @@ -473,4 +474,48 @@ Please double check your script's path.`, Please check Hardhat's output for more details.`, }, }, + NETWORK: { + INVALID_URL: { + number: 700, + messageTemplate: "Invalid URL {value} for network or forking.", + websiteTitle: "Invalid URL for network or forking", + websiteDescription: `You are trying to connect to a network with an invalid network or forking URL. + +Please check that you are sending a valid URL string for the network or forking \`URL\` parameter.`, + }, + INVALID_REQUEST_PARAMS: { + number: 701, + messageTemplate: + "Invalid request arguments: only array parameters are supported.", + websiteTitle: "Invalid method parameters", + websiteDescription: + "The JSON-RPC request parameters are invalid. You are trying to make an EIP-1193 request with object parameters, but only array parameters are supported. Ensure that the 'params' parameter is correctly specified as an array in your JSON-RPC request.", + }, + INVALID_JSON_RESPONSE: { + number: 702, + messageTemplate: "Invalid JSON-RPC response received: {response}", + websiteTitle: "Invalid JSON-RPC response", + websiteDescription: `One of your JSON-RPC requests received an invalid response. + +Please make sure your node is running, and check your internet connection and networks config.`, + }, + CONNECTION_REFUSED: { + number: 703, + messageTemplate: `Cannot connect to the network {network}. +Please make sure your node is running, and check your internet connection and networks config`, + websiteTitle: "Cannot connect to the network", + websiteDescription: `Cannot connect to the network. + +Please make sure your node is running, and check your internet connection and networks config.`, + }, + NETWORK_TIMEOUT: { + number: 704, + messageTemplate: `Network connection timed out. +Please check your internet connection and networks config`, + websiteTitle: "Network timeout", + websiteDescription: `One of your JSON-RPC requests timed out. + +Please make sure your node is running, and check your internet connection and networks config.`, + }, + }, } as const; diff --git a/v-next/hardhat-errors/src/errors.ts b/v-next/hardhat-errors/src/errors.ts index 0f7740f974..f7494cf786 100644 --- a/v-next/hardhat-errors/src/errors.ts +++ b/v-next/hardhat-errors/src/errors.ts @@ -131,7 +131,7 @@ export class HardhatError< other: unknown, descriptor?: ErrorDescriptor, ): other is HardhatError { - if (!isObject(other) || other === null) { + if (!isObject(other)) { return false; } @@ -200,7 +200,7 @@ export class HardhatPluginError extends CustomError { public static isHardhatPluginError( other: unknown, ): other is HardhatPluginError { - if (!isObject(other) || other === null) { + if (!isObject(other)) { return false; } diff --git a/v-next/hardhat-utils/src/errors/request.ts b/v-next/hardhat-utils/src/errors/request.ts index 499916744e..578e83f10a 100644 --- a/v-next/hardhat-utils/src/errors/request.ts +++ b/v-next/hardhat-utils/src/errors/request.ts @@ -2,6 +2,7 @@ import type UndiciT from "undici"; import { CustomError } from "../error.js"; import { sanitizeUrl } from "../internal/request.js"; +import { isObject } from "../lang.js"; export class RequestError extends CustomError { constructor(url: string, type: UndiciT.Dispatcher.HttpMethod, cause?: Error) { @@ -20,3 +21,64 @@ export class DispatcherError extends CustomError { super(`Failed to create dispatcher: ${message}`, cause); } } + +export class RequestTimeoutError extends CustomError { + constructor(url: string, cause?: Error) { + super(`Request to ${sanitizeUrl(url)} timed out`, cause); + } +} + +export class ConnectionRefusedError extends CustomError { + constructor(url: string, cause?: Error) { + super(`Connection to ${sanitizeUrl(url)} was refused`, cause); + } +} + +export class ResponseStatusCodeError extends CustomError { + public readonly statusCode: number; + public readonly headers: + | string[] + | Record + | null; + public readonly body: null | Record | string; + + constructor(url: string, cause: Error) { + super(`Received an unexpected status code from ${sanitizeUrl(url)}`, cause); + this.statusCode = + "statusCode" in cause && typeof cause.statusCode === "number" + ? cause.statusCode + : -1; + this.headers = this.#extractHeaders(cause); + this.body = "body" in cause && isObject(cause.body) ? cause.body : null; + } + + #extractHeaders( + cause: Error, + ): string[] | Record | null { + if ("headers" in cause) { + const headers = cause.headers; + if (Array.isArray(headers)) { + return headers; + } else if (this.#isValidHeaders(headers)) { + return headers; + } + } + return null; + } + + #isValidHeaders( + headers: unknown, + ): headers is Record { + if (!isObject(headers)) { + return false; + } + + return Object.values(headers).every( + (header) => + typeof header === "string" || + (Array.isArray(header) && + header.every((item: unknown) => typeof item === "string")) || + header === undefined, + ); + } +} diff --git a/v-next/hardhat-utils/src/internal/request.ts b/v-next/hardhat-utils/src/internal/request.ts index 0cc2491f21..32ddd043b3 100644 --- a/v-next/hardhat-utils/src/internal/request.ts +++ b/v-next/hardhat-utils/src/internal/request.ts @@ -6,11 +6,15 @@ import path from "node:path"; import url from "node:url"; import { mkdir } from "../fs.js"; +import { isObject } from "../lang.js"; import { + ConnectionRefusedError, DEFAULT_MAX_REDIRECTS, DEFAULT_TIMEOUT_IN_MILLISECONDS, DEFAULT_USER_AGENT, getDispatcher, + RequestTimeoutError, + ResponseStatusCodeError, } from "../request.js"; export async function generateTempFilePath(filePath: string): Promise { @@ -34,7 +38,7 @@ export async function getBaseRequestOptions( signal?: EventEmitter | AbortSignal | undefined; dispatcher: UndiciT.Dispatcher; headers: Record; - throwOnError: boolean; + throwOnError: true; }> { const { Dispatcher } = await import("undici"); const dispatcher = @@ -135,3 +139,27 @@ export function sanitizeUrl(requestUrl: string): string { const parsedUrl = new URL(requestUrl); return url.format(parsedUrl, { auth: false, search: false, fragment: false }); } + +export function handleError(e: Error, requestUrl: string): void { + let causeCode: unknown; + if (isObject(e.cause)) { + causeCode = e.cause.code; + } + const errorCode = "code" in e ? e.code : causeCode; + + if (errorCode === "ECONNREFUSED") { + throw new ConnectionRefusedError(requestUrl, e); + } + + if ( + errorCode === "UND_ERR_CONNECT_TIMEOUT" || + errorCode === "UND_ERR_HEADERS_TIMEOUT" || + errorCode === "UND_ERR_BODY_TIMEOUT" + ) { + throw new RequestTimeoutError(requestUrl, e); + } + + if (errorCode === "UND_ERR_RESPONSE_STATUS_CODE") { + throw new ResponseStatusCodeError(requestUrl, e); + } +} diff --git a/v-next/hardhat-utils/src/lang.ts b/v-next/hardhat-utils/src/lang.ts index 141582364e..6874809f04 100644 --- a/v-next/hardhat-utils/src/lang.ts +++ b/v-next/hardhat-utils/src/lang.ts @@ -36,3 +36,13 @@ export function isObject( ): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } + +/** + * Pauses the execution for the specified number of seconds. + * + * @param seconds The number of seconds to pause the execution. + * @returns A promise that resolves after the specified number of seconds. + */ +export async function sleep(seconds: number): Promise { + await new Promise((resolve) => setTimeout(resolve, seconds * 1000)); +} diff --git a/v-next/hardhat-utils/src/request.ts b/v-next/hardhat-utils/src/request.ts index e535a569d4..44c23ff221 100644 --- a/v-next/hardhat-utils/src/request.ts +++ b/v-next/hardhat-utils/src/request.ts @@ -20,6 +20,7 @@ import { getBasicDispatcher, getPoolDispatcher, getProxyDispatcher, + handleError, } from "./internal/request.js"; export const DEFAULT_TIMEOUT_IN_MILLISECONDS = 30_000; @@ -27,6 +28,10 @@ export const DEFAULT_MAX_REDIRECTS = 10; export const DEFAULT_POOL_MAX_CONNECTIONS = 128; export const DEFAULT_USER_AGENT = "Hardhat"; +export type Dispatcher = UndiciT.Dispatcher; +export type TestDispatcher = UndiciT.MockAgent; +export type Interceptable = UndiciT.Interceptable; + /** * Options to configure the dispatcher. * @@ -64,7 +69,9 @@ export interface RequestOptions { * @param requestOptions The options to configure the request. See {@link RequestOptions}. * @param dispatcherOrDispatcherOptions Either a dispatcher or dispatcher options. See {@link DispatcherOptions}. * @returns The response data object. See {@link https://undici.nodejs.org/#/docs/api/Dispatcher?id=parameter-responsedata}. - * @throws RequestError If the request fails. + * @throws ConnectionRefusedError If the connection is refused by the server. + * @throws RequestTimeoutError If the request times out. + * @throws RequestError If the request fails for any other reason. */ export async function getRequest( url: string, @@ -85,6 +92,9 @@ export async function getRequest( }); } catch (e) { ensureError(e); + + handleError(e, url); + throw new RequestError(url, "GET", e); } } @@ -97,7 +107,9 @@ export async function getRequest( * @param requestOptions The options to configure the request. See {@link RequestOptions}. * @param dispatcherOrDispatcherOptions Either a dispatcher or dispatcher options. See {@link DispatcherOptions}. * @returns The response data object. See {@link https://undici.nodejs.org/#/docs/api/Dispatcher?id=parameter-responsedata}. - * @throws RequestError If the request fails. + * @throws ConnectionRefusedError If the connection is refused by the server. + * @throws RequestTimeoutError If the request times out. + * @throws RequestError If the request fails for any other reason. */ export async function postJsonRequest( url: string, @@ -124,6 +136,9 @@ export async function postJsonRequest( }); } catch (e) { ensureError(e); + + handleError(e, url); + throw new RequestError(url, "POST", e); } } @@ -136,7 +151,9 @@ export async function postJsonRequest( * @param requestOptions The options to configure the request. See {@link RequestOptions}. * @param dispatcherOrDispatcherOptions Either a dispatcher or dispatcher options. See {@link DispatcherOptions}. * @returns The response data object. See {@link https://undici.nodejs.org/#/docs/api/Dispatcher?id=parameter-responsedata}. - * @throws RequestError If the request fails. + * @throws ConnectionRefusedError If the connection is refused by the server. + * @throws RequestTimeoutError If the request times out. + * @throws RequestError If the request fails for any other reason. */ export async function postFormRequest( url: string, @@ -164,6 +181,9 @@ export async function postFormRequest( }); } catch (e) { ensureError(e); + + handleError(e, url); + throw new RequestError(url, "POST", e); } } @@ -175,7 +195,9 @@ export async function postFormRequest( * @param destination The absolute path to save the file to. * @param requestOptions The options to configure the request. See {@link RequestOptions}. * @param dispatcherOrDispatcherOptions Either a dispatcher or dispatcher options. See {@link DispatcherOptions}. - * @throws DownloadFailedError If the download fails. + * @throws ConnectionRefusedError If the connection is refused by the server. + * @throws RequestTimeoutError If the request times out. + * @throws DownloadFailedError If the download fails for any other reason. */ export async function download( url: string, @@ -204,6 +226,9 @@ export async function download( await move(tempFilePath, destination); } catch (e) { ensureError(e); + + handleError(e, url); + throw new DownloadError(url, e); } } @@ -228,7 +253,7 @@ export async function getDispatcher( maxConnections, isTestDispatcher, }: DispatcherOptions = {}, -): Promise { +): Promise { try { if (pool !== undefined && proxy !== undefined) { throw new Error( @@ -255,6 +280,17 @@ export async function getDispatcher( } } +export async function getTestDispatcher( + options: { + timeout?: number; + } = {}, +): Promise { + const { MockAgent } = await import("undici"); + + const baseOptions = getBaseDispatcherOptions(options.timeout, true); + return new MockAgent(baseOptions); +} + /** * Determines whether a proxy should be used for a given url. * @@ -280,8 +316,26 @@ export function shouldUseProxy(url: string): boolean { return true; } +/** + * Determines whether an absolute url is valid. + * + * @param url The url to check. + * @returns `true` if the url is valid, `false` otherwise. + */ +export function isValidUrl(url: string): boolean { + try { + new URL(url); + return true; + } catch { + return false; + } +} + export { + ConnectionRefusedError, + DispatcherError, DownloadError, RequestError, - DispatcherError, + RequestTimeoutError, + ResponseStatusCodeError, } from "./errors/request.js"; diff --git a/v-next/hardhat-utils/test/helpers/request.ts b/v-next/hardhat-utils/test/helpers/request.ts index 79e0cf8889..f48063524a 100644 --- a/v-next/hardhat-utils/test/helpers/request.ts +++ b/v-next/hardhat-utils/test/helpers/request.ts @@ -1,27 +1,22 @@ -import type { DispatcherOptions } from "../../src/request.js"; -import type { Interceptable } from "undici"; +import type { Interceptable } from "../../src/request.js"; import { after, before } from "node:test"; -import { MockAgent } from "undici"; +import { getTestDispatcher } from "../../src/request.js"; -export function getTestDispatcherOptions( - options: DispatcherOptions = {}, -): DispatcherOptions & { isTestDispatcher: true } { - return { - ...options, - isTestDispatcher: true, - }; +interface InitializeOptions { + url?: string; + timeout?: number; } -const mockAgent = new MockAgent({ - keepAliveTimeout: 10, - keepAliveMaxTimeout: 10, -}); +export const initializeTestDispatcher = async ( + options: InitializeOptions = {}, +): Promise => { + const { url = "http://localhost", timeout } = options; -export const mockPool: Interceptable = mockAgent.get("http://localhost:3000"); + const mockAgent = await getTestDispatcher({ timeout }); + const interceptor = mockAgent.get(url); -export const setupRequestMocking: () => void = () => { before(() => { mockAgent.disableNetConnect(); }); @@ -29,4 +24,6 @@ export const setupRequestMocking: () => void = () => { after(() => { mockAgent.enableNetConnect(); }); + + return interceptor; }; diff --git a/v-next/hardhat-utils/test/lang.ts b/v-next/hardhat-utils/test/lang.ts index ac4c622700..3f64b4a57e 100644 --- a/v-next/hardhat-utils/test/lang.ts +++ b/v-next/hardhat-utils/test/lang.ts @@ -3,7 +3,7 @@ import { describe, it } from "node:test"; import { expectTypeOf } from "expect-type"; -import { deepClone, deepEqual, isObject } from "../src/lang.js"; +import { deepClone, deepEqual, isObject, sleep } from "../src/lang.js"; describe("lang", () => { describe("deepClone", () => { @@ -359,6 +359,10 @@ describe("lang", () => { isObject(new Set()), "new Set() is an object, but isObject returned false", ); + assert.ok( + isObject(new Error()), + "new Error() is an object, but isObject returned false", + ); }); it("Should return false for non-objects", () => { @@ -388,4 +392,41 @@ describe("lang", () => { ); }); }); + + describe("sleep", () => { + it("should wait for the specified time", async () => { + const start = Date.now(); + await sleep(1); + const end = Date.now(); + + assert.ok(end - start >= 1000, "sleep did not wait for 1 second"); + }); + + it("should handle zero delay", async () => { + const start = Date.now(); + await sleep(0); + const end = Date.now(); + + assert.ok(end - start < 100, "sleep did not handle zero delay correctly"); + }); + + it("should handle negative delay", async () => { + const start = Date.now(); + await sleep(-1); + const end = Date.now(); + + assert.ok( + end - start < 100, + "sleep did not handle negative delay correctly", + ); + }); + + it("should handle non-integer delay", async () => { + const start = Date.now(); + await sleep(0.5); + const end = Date.now(); + + assert.ok(end - start >= 500, "sleep did not wait for 0.5 seconds"); + }); + }); }); diff --git a/v-next/hardhat-utils/test/request.ts b/v-next/hardhat-utils/test/request.ts index 09e96d22c3..b81a31e09e 100644 --- a/v-next/hardhat-utils/test/request.ts +++ b/v-next/hardhat-utils/test/request.ts @@ -24,65 +24,54 @@ import { download, getDispatcher, shouldUseProxy, + isValidUrl, } from "../src/request.js"; import { useTmpDir } from "./helpers/fs.js"; -import { - getTestDispatcherOptions, - mockPool, - setupRequestMocking, -} from "./helpers/request.js"; +import { initializeTestDispatcher } from "./helpers/request.js"; describe("Requests util", () => { describe("getDispatcher", () => { it("Should return a ProxyAgent dispatcher if a proxy url was provided", async () => { - const url = "http://localhost"; - const options = getTestDispatcherOptions({ + const dispatcher = await getDispatcher("http://localhost", { proxy: "http://proxy", }); - const dispatcher = await getDispatcher(url, options); assert.ok(dispatcher instanceof ProxyAgent, "Should return a ProxyAgent"); }); it("Should return a Pool dispatcher if pool is true", async () => { - const url = "http://localhost"; - const options = getTestDispatcherOptions({ + const dispatcher = await getDispatcher("http://localhost", { pool: true, }); - const dispatcher = await getDispatcher(url, options); assert.ok(dispatcher instanceof Pool, "Should return a Pool"); }); it("Should throw if both pool and proxy are set", async () => { - const url = "http://localhost"; - const options = getTestDispatcherOptions({ - pool: true, - proxy: "http://proxy", - }); - - await assert.rejects(getDispatcher(url, options), { - name: "DispatcherError", - message: - "Failed to create dispatcher: The pool and proxy options can't be used at the same time", - }); + await assert.rejects( + getDispatcher("http://localhost", { + pool: true, + proxy: "http://proxy", + }), + { + name: "DispatcherError", + message: + "Failed to create dispatcher: The pool and proxy options can't be used at the same time", + }, + ); }); it("Should return an Agent dispatcher if proxy is not set and pool is false", async () => { - const url = "http://localhost"; - const options = getTestDispatcherOptions({ + const dispatcher = await getDispatcher("http://localhost", { pool: false, }); - const dispatcher = await getDispatcher(url, options); assert.ok(dispatcher instanceof Agent, "Should return an Agent"); }); it("Should return an Agent dispatcher if proxy is not set and pool is not set", async () => { - const url = "http://localhost"; - const options = getTestDispatcherOptions(); - const dispatcher = await getDispatcher(url, options); + const dispatcher = await getDispatcher("http://localhost"); assert.ok(dispatcher instanceof Agent, "Should return an Agent"); }); @@ -214,12 +203,9 @@ describe("Requests util", () => { it("Should return a dispatcher based on the provided options", async () => { const url = "http://localhost"; - const dispatcherOptions = getTestDispatcherOptions({ pool: true }); - const { dispatcher } = await getBaseRequestOptions( - url, - undefined, - dispatcherOptions, - ); + const { dispatcher } = await getBaseRequestOptions(url, undefined, { + pool: true, + }); assert.ok(dispatcher instanceof Pool, "Should return a Pool"); }); @@ -247,9 +233,9 @@ describe("Requests util", () => { }); }); - describe("getRequest", () => { - setupRequestMocking(); - const url = "http://localhost:3000/"; + describe("getRequest", async () => { + const interceptor = await initializeTestDispatcher(); + const url = "http://localhost/"; const baseInterceptorOptions = { path: "/", method: "GET", @@ -259,8 +245,8 @@ describe("Requests util", () => { }; it("Should make a basic get request", async () => { - mockPool.intercept(baseInterceptorOptions).reply(200, {}); - const response = await getRequest(url, undefined, mockPool); + interceptor.intercept(baseInterceptorOptions).reply(200, {}); + const response = await getRequest(url, undefined, interceptor); assert.notEqual(response, undefined, "Should return a response"); assert.equal(response.statusCode, 200); @@ -272,10 +258,10 @@ describe("Requests util", () => { foo: "bar", baz: "qux", }; - mockPool + interceptor .intercept({ ...baseInterceptorOptions, query: queryParams }) .reply(200, {}); - const response = await getRequest(url, { queryParams }, mockPool); + const response = await getRequest(url, { queryParams }, interceptor); assert.notEqual(response, undefined, "Should return a response"); assert.equal(response.statusCode, 200); @@ -286,13 +272,13 @@ describe("Requests util", () => { const extraHeaders = { "X-Custom-Header": "value", }; - mockPool + interceptor .intercept({ ...baseInterceptorOptions, headers: { ...baseInterceptorOptions.headers, ...extraHeaders }, }) .reply(200, {}); - const response = await getRequest(url, { extraHeaders }, mockPool); + const response = await getRequest(url, { extraHeaders }, interceptor); assert.notEqual(response, undefined, "Should return a response"); assert.equal(response.statusCode, 200); @@ -301,11 +287,11 @@ describe("Requests util", () => { it("Should allow aborting a request using an abort signal", async () => { const abortController = new AbortController(); - mockPool.intercept(baseInterceptorOptions).reply(200, {}); + interceptor.intercept(baseInterceptorOptions).reply(200, {}); const requestPromise = getRequest( url, { abortSignal: abortController.signal }, - mockPool, + interceptor, ); abortController.abort(); @@ -318,20 +304,20 @@ describe("Requests util", () => { }); it("Should throw if the request fails", async () => { - mockPool + interceptor .intercept(baseInterceptorOptions) .reply(500, "Internal Server Error"); - await assert.rejects(getRequest(url, undefined, mockPool), { - name: "RequestError", - message: `Failed to make GET request to ${url}`, + await assert.rejects(getRequest(url, undefined, interceptor), { + name: "ResponseStatusCodeError", + message: `Received an unexpected status code from ${url}`, }); }); }); - describe("postJsonRequest", () => { - setupRequestMocking(); - const url = "http://localhost:3000/"; + describe("postJsonRequest", async () => { + const interceptor = await initializeTestDispatcher(); + const url = "http://localhost/"; const body = { foo: "bar" }; const baseInterceptorOptions = { path: "/", @@ -344,8 +330,8 @@ describe("Requests util", () => { }; it("Should make a basic post request", async () => { - mockPool.intercept(baseInterceptorOptions).reply(200, {}); - const response = await postJsonRequest(url, body, undefined, mockPool); + interceptor.intercept(baseInterceptorOptions).reply(200, {}); + const response = await postJsonRequest(url, body, undefined, interceptor); assert.notEqual(response, undefined, "Should return a response"); assert.equal(response.statusCode, 200); @@ -356,7 +342,7 @@ describe("Requests util", () => { const queryParams = { baz: "qux", }; - mockPool + interceptor .intercept({ ...baseInterceptorOptions, query: queryParams, @@ -366,7 +352,7 @@ describe("Requests util", () => { url, body, { queryParams }, - mockPool, + interceptor, ); assert.notEqual(response, undefined, "Should return a response"); @@ -378,7 +364,7 @@ describe("Requests util", () => { const extraHeaders = { "X-Custom-Header": "value", }; - mockPool + interceptor .intercept({ ...baseInterceptorOptions, headers: { ...baseInterceptorOptions.headers, ...extraHeaders }, @@ -388,7 +374,7 @@ describe("Requests util", () => { url, body, { extraHeaders }, - mockPool, + interceptor, ); assert.notEqual(response, undefined, "Should return a response"); @@ -398,12 +384,12 @@ describe("Requests util", () => { it("Should allow aborting a request using an abort signal", async () => { const abortController = new AbortController(); - mockPool.intercept(baseInterceptorOptions).reply(200, {}); + interceptor.intercept(baseInterceptorOptions).reply(200, {}); const requestPromise = postJsonRequest( url, body, { abortSignal: abortController.signal }, - mockPool, + interceptor, ); abortController.abort(); @@ -416,20 +402,20 @@ describe("Requests util", () => { }); it("Should throw if the request fails", async () => { - mockPool + interceptor .intercept(baseInterceptorOptions) .reply(500, "Internal Server Error"); - await assert.rejects(postJsonRequest(url, body, undefined, mockPool), { - name: "RequestError", - message: `Failed to make POST request to ${url}`, + await assert.rejects(postJsonRequest(url, body, undefined, interceptor), { + name: "ResponseStatusCodeError", + message: `Received an unexpected status code from ${url}`, }); }); }); - describe("postFormRequest", () => { - setupRequestMocking(); - const url = "http://localhost:3000/"; + describe("postFormRequest", async () => { + const interceptor = await initializeTestDispatcher(); + const url = "http://localhost/"; const body = { foo: "bar" }; const baseInterceptorOptions = { path: "/", @@ -442,8 +428,8 @@ describe("Requests util", () => { }; it("Should make a basic post request", async () => { - mockPool.intercept(baseInterceptorOptions).reply(200, {}); - const response = await postFormRequest(url, body, undefined, mockPool); + interceptor.intercept(baseInterceptorOptions).reply(200, {}); + const response = await postFormRequest(url, body, undefined, interceptor); assert.notEqual(response, undefined, "Should return a response"); assert.equal(response.statusCode, 200); @@ -454,7 +440,7 @@ describe("Requests util", () => { const queryParams = { baz: "qux", }; - mockPool + interceptor .intercept({ ...baseInterceptorOptions, query: queryParams, @@ -464,7 +450,7 @@ describe("Requests util", () => { url, body, { queryParams }, - mockPool, + interceptor, ); assert.notEqual(response, undefined, "Should return a response"); @@ -476,7 +462,7 @@ describe("Requests util", () => { const extraHeaders = { "X-Custom-Header": "value", }; - mockPool + interceptor .intercept({ ...baseInterceptorOptions, headers: { ...baseInterceptorOptions.headers, ...extraHeaders }, @@ -486,7 +472,7 @@ describe("Requests util", () => { url, body, { extraHeaders }, - mockPool, + interceptor, ); assert.notEqual(response, undefined, "Should return a response"); @@ -496,12 +482,12 @@ describe("Requests util", () => { it("Should allow aborting a request using an abort signal", async () => { const abortController = new AbortController(); - mockPool.intercept(baseInterceptorOptions).reply(200, {}); + interceptor.intercept(baseInterceptorOptions).reply(200, {}); const requestPromise = postFormRequest( url, body, { abortSignal: abortController.signal }, - mockPool, + interceptor, ); abortController.abort(); @@ -514,21 +500,21 @@ describe("Requests util", () => { }); it("Should throw if the request fails", async () => { - mockPool + interceptor .intercept(baseInterceptorOptions) .reply(500, "Internal Server Error"); - await assert.rejects(postFormRequest(url, body, undefined, mockPool), { - name: "RequestError", - message: `Failed to make POST request to ${url}`, + await assert.rejects(postFormRequest(url, body, undefined, interceptor), { + name: "ResponseStatusCodeError", + message: `Received an unexpected status code from ${url}`, }); }); }); - describe("download", () => { + describe("download", async () => { + const interceptor = await initializeTestDispatcher(); const getTmpDir = useTmpDir("request"); - setupRequestMocking(); - const url = "http://localhost:3000/"; + const url = "http://localhost/"; const baseInterceptorOptions = { path: "/", method: "GET", @@ -539,8 +525,8 @@ describe("Requests util", () => { it("Should download a file", async () => { const destination = path.join(getTmpDir(), "file.txt"); - mockPool.intercept(baseInterceptorOptions).reply(200, "file content"); - await download(url, destination, undefined, mockPool); + interceptor.intercept(baseInterceptorOptions).reply(200, "file content"); + await download(url, destination, undefined, interceptor); assert.ok(await exists(destination), "Should create the file"); assert.equal(await readUtf8File(destination), "file content"); @@ -548,13 +534,13 @@ describe("Requests util", () => { it("Should throw if the request fails", async () => { const destination = path.join(getTmpDir(), "file.txt"); - mockPool + interceptor .intercept(baseInterceptorOptions) .reply(500, "Internal Server Error"); - await assert.rejects(download(url, destination, undefined, mockPool), { - name: "DownloadError", - message: `Failed to download file from ${url}`, + await assert.rejects(download(url, destination, undefined, interceptor), { + name: "ResponseStatusCodeError", + message: `Received an unexpected status code from ${url}`, }); }); }); @@ -606,4 +592,24 @@ describe("Requests util", () => { assert.equal(shouldUseProxy("ftp://example.com"), false); }); }); + + describe("isValidUrl", () => { + it("should return true for a valid URL", () => { + assert.equal(isValidUrl("http://example.com"), true); + assert.equal(isValidUrl("https://example.com"), true); + assert.equal(isValidUrl("ftp://example.com"), true); + assert.equal(isValidUrl("http://example.com:8080"), true); + assert.equal( + isValidUrl("http://example.com/path?name=value#fragment"), + true, + ); + }); + + it("should return false for an invalid URL", () => { + assert.equal(isValidUrl("example.com"), false); + assert.equal(isValidUrl("example"), false); + assert.equal(isValidUrl(""), false); + assert.equal(isValidUrl("/relative/path"), false); + }); + }); }); diff --git a/v-next/hardhat/src/internal/network/http-provider.ts b/v-next/hardhat/src/internal/network/http-provider.ts new file mode 100644 index 0000000000..11183fa6e1 --- /dev/null +++ b/v-next/hardhat/src/internal/network/http-provider.ts @@ -0,0 +1,317 @@ +import type { + JsonRpcResponse, + JsonRpcRequest, + SuccessfulJsonRpcResponse, +} from "./utils/json-rpc.js"; +import type { + EthereumProvider, + RequestArguments, +} from "../../types/providers.js"; +import type { + Dispatcher, + RequestOptions, +} from "@ignored/hardhat-vnext-utils/request"; + +import EventEmitter from "node:events"; +import util from "node:util"; + +import { HardhatError } from "@ignored/hardhat-vnext-errors"; +import { ensureError } from "@ignored/hardhat-vnext-utils/error"; +import { sleep, isObject } from "@ignored/hardhat-vnext-utils/lang"; +import { + getDispatcher, + isValidUrl, + postJsonRequest, + shouldUseProxy, + ConnectionRefusedError, + RequestTimeoutError, + ResponseStatusCodeError, +} from "@ignored/hardhat-vnext-utils/request"; + +import { getHardhatVersion } from "../utils/package.js"; + +import { ProviderError, LimitExceededError } from "./provider-errors.js"; +import { + getJsonRpcRequest, + isFailedJsonRpcResponse, + parseJsonRpcResponse, +} from "./utils/json-rpc.js"; + +const TOO_MANY_REQUEST_STATUS = 429; +const MAX_RETRIES = 6; +const MAX_RETRY_WAIT_TIME_SECONDS = 5; + +export class HttpProvider extends EventEmitter implements EthereumProvider { + readonly #url: string; + readonly #networkName: string; + readonly #extraHeaders: Record; + readonly #dispatcher: Dispatcher; + #nextRequestId = 1; + + /** + * Creates a new instance of `HttpProvider`. + */ + public static async create({ + url, + networkName, + extraHeaders = {}, + timeout, + }: { + url: string; + networkName: string; + extraHeaders?: Record; + timeout: number; + }): Promise { + if (!isValidUrl(url)) { + throw new HardhatError(HardhatError.ERRORS.NETWORK.INVALID_URL, { + value: url, + }); + } + + const dispatcher = await getHttpDispatcher(url, timeout); + + const httpProvider = new HttpProvider( + url, + networkName, + extraHeaders, + dispatcher, + ); + + return httpProvider; + } + + /** + * @private + * + * This constructor is intended for internal use only. + * Use the static method {@link HttpProvider.create} to create an instance of + * `HttpProvider`. + */ + constructor( + url: string, + networkName: string, + extraHeaders: Record, + dispatcher: Dispatcher, + ) { + super(); + + this.#url = url; + this.#networkName = networkName; + this.#extraHeaders = extraHeaders; + this.#dispatcher = dispatcher; + } + + public async request( + requestArguments: RequestArguments, + ): Promise { + const { method, params } = requestArguments; + + const jsonRpcRequest = getJsonRpcRequest( + this.#nextRequestId++, + method, + params, + ); + const jsonRpcResponse = await this.#fetchJsonRpcResponse(jsonRpcRequest); + + if (isFailedJsonRpcResponse(jsonRpcResponse)) { + const error = new ProviderError( + jsonRpcResponse.error.message, + jsonRpcResponse.error.code, + ); + error.data = jsonRpcResponse.error.data; + + // eslint-disable-next-line no-restricted-syntax -- allow throwing ProviderError + throw error; + } + + // TODO: emit hardhat network events (hardhat_reset, evm_revert) + + return jsonRpcResponse.result; + } + + public send( + method: string, + params?: unknown[], + ): Promise { + return this.request({ method, params }); + } + + public sendAsync( + jsonRpcRequest: JsonRpcRequest, + callback: (error: any, jsonRpcResponse: JsonRpcResponse) => void, + ): void { + const handleJsonRpcRequest = async () => { + let jsonRpcResponse: JsonRpcResponse; + try { + const result = await this.request({ + method: jsonRpcRequest.method, + params: jsonRpcRequest.params, + }); + jsonRpcResponse = { + jsonrpc: "2.0", + id: jsonRpcRequest.id, + result, + }; + } catch (error) { + ensureError(error); + + if (!("code" in error) || error.code === undefined) { + throw error; + } + + /* eslint-disable-next-line @typescript-eslint/restrict-template-expressions + -- Allow string interpolation of unknown `error.code`. It will be converted + to a number, and we will handle NaN cases appropriately afterwards. */ + const errorCode = parseInt(`${error.code}`, 10); + jsonRpcResponse = { + jsonrpc: "2.0", + id: jsonRpcRequest.id, + error: { + code: !isNaN(errorCode) ? errorCode : -1, + message: error.message, + data: { + stack: error.stack, + name: error.name, + }, + }, + }; + } + + return jsonRpcResponse; + }; + + util.callbackify(handleJsonRpcRequest)(callback); + } + + async #fetchJsonRpcResponse( + jsonRpcRequest: JsonRpcRequest, + retryCount?: number, + ): Promise; + async #fetchJsonRpcResponse( + jsonRpcRequest: JsonRpcRequest[], + retryCount?: number, + ): Promise; + async #fetchJsonRpcResponse( + jsonRpcRequest: JsonRpcRequest | JsonRpcRequest[], + retryCount?: number, + ): Promise; + async #fetchJsonRpcResponse( + jsonRpcRequest: JsonRpcRequest | JsonRpcRequest[], + retryCount = 0, + ): Promise { + const requestOptions: RequestOptions = { + extraHeaders: { + "User-Agent": `Hardhat ${await getHardhatVersion()}`, + ...this.#extraHeaders, + }, + }; + + let response; + try { + response = await postJsonRequest( + this.#url, + jsonRpcRequest, + requestOptions, + this.#dispatcher, + ); + } catch (e) { + if (e instanceof ConnectionRefusedError) { + throw new HardhatError( + HardhatError.ERRORS.NETWORK.CONNECTION_REFUSED, + { network: this.#networkName }, + e, + ); + } + + if (e instanceof RequestTimeoutError) { + throw new HardhatError(HardhatError.ERRORS.NETWORK.NETWORK_TIMEOUT, e); + } + + /** + * Nodes can have a rate limit mechanism to avoid abuse. This logic checks + * if the response indicates a rate limit has been reached and retries the + * request after the specified time. + */ + if ( + e instanceof ResponseStatusCodeError && + e.statusCode === TOO_MANY_REQUEST_STATUS + ) { + const retryAfterHeader = + isObject(e.headers) && typeof e.headers["retry-after"] === "string" + ? e.headers["retry-after"] + : undefined; + const retryAfterSeconds = this.#getRetryAfterSeconds( + retryAfterHeader, + retryCount, + ); + if (this.#shouldRetryRequest(retryAfterSeconds, retryCount)) { + return this.#retry(jsonRpcRequest, retryAfterSeconds, retryCount); + } + + // eslint-disable-next-line no-restricted-syntax -- allow throwing ProviderError + throw new LimitExceededError(e); + } + + throw e; + } + + return parseJsonRpcResponse(await response.body.text()); + } + + #getRetryAfterSeconds( + retryAfterHeader: string | undefined, + retryCount: number, + ) { + const parsedRetryAfter = parseInt(`${retryAfterHeader}`, 10); + if (isNaN(parsedRetryAfter)) { + // use an exponential backoff if the retry-after header can't be parsed + return Math.min(2 ** retryCount, MAX_RETRY_WAIT_TIME_SECONDS); + } + + return parsedRetryAfter; + } + + #shouldRetryRequest(retryAfterSeconds: number, retryCount: number) { + if (retryCount > MAX_RETRIES) { + return false; + } + + if (retryAfterSeconds > MAX_RETRY_WAIT_TIME_SECONDS) { + return false; + } + + return true; + } + + async #retry( + request: JsonRpcRequest | JsonRpcRequest[], + retryAfterSeconds: number, + retryCount: number, + ) { + await sleep(retryAfterSeconds); + return this.#fetchJsonRpcResponse(request, retryCount + 1); + } +} + +/** + * Gets either a pool or proxy dispatcher depending on the URL and the + * environment variable `http_proxy`. This function is used internally by + * `HttpProvider.create` and should not be used directly. + */ +export async function getHttpDispatcher( + url: string, + timeout?: number, +): Promise { + let dispatcher: Dispatcher; + + if (process.env.http_proxy !== undefined && shouldUseProxy(url)) { + dispatcher = await getDispatcher(url, { + proxy: process.env.http_proxy, + timeout, + }); + } else { + dispatcher = await getDispatcher(url, { pool: true, timeout }); + } + + return dispatcher; +} diff --git a/v-next/hardhat/src/internal/network/provider-errors.ts b/v-next/hardhat/src/internal/network/provider-errors.ts new file mode 100644 index 0000000000..41bf964317 --- /dev/null +++ b/v-next/hardhat/src/internal/network/provider-errors.ts @@ -0,0 +1,47 @@ +import type { ProviderRpcError } from "../../types/providers.js"; + +import { CustomError } from "@ignored/hardhat-vnext-utils/error"; +import { isObject } from "@ignored/hardhat-vnext-utils/lang"; + +const IS_PROVIDER_ERROR_PROPERTY_NAME = "_isProviderError"; + +/** + * Codes taken from: https://github.com/ethereum/EIPs/blob/master/EIPS/eip-1474.md#error-codes + */ +export class ProviderError extends CustomError implements ProviderRpcError { + public code: number; + public data?: unknown; + + constructor(message: string, code: number, parentError?: Error) { + super(message, parentError); + this.code = code; + + Object.defineProperty(this, IS_PROVIDER_ERROR_PROPERTY_NAME, { + configurable: false, + enumerable: false, + writable: false, + value: true, + }); + } + + public static isProviderError(other: unknown): other is ProviderError { + if (!isObject(other)) { + return false; + } + + const isProviderErrorProperty = Object.getOwnPropertyDescriptor( + other, + IS_PROVIDER_ERROR_PROPERTY_NAME, + ); + + return isProviderErrorProperty?.value === true; + } +} + +export class LimitExceededError extends ProviderError { + public static readonly CODE = -32005; + + constructor(parent?: Error) { + super("Request exceeds defined limit", LimitExceededError.CODE, parent); + } +} diff --git a/v-next/hardhat/src/internal/network/utils/json-rpc.ts b/v-next/hardhat/src/internal/network/utils/json-rpc.ts new file mode 100644 index 0000000000..e6bd2a2d48 --- /dev/null +++ b/v-next/hardhat/src/internal/network/utils/json-rpc.ts @@ -0,0 +1,141 @@ +import { HardhatError } from "@ignored/hardhat-vnext-errors"; +import { isObject } from "@ignored/hardhat-vnext-utils/lang"; + +/** + * The JSON-RPC 2.0 request object. + * + * For typing a JSON-RPC notification request, use `JsonRpcNotificationRequest`. + * + * Although the `params` field can be an object according to the specification, + * we only support arrays. The interface remains unchanged to comply with the EIP + * and to type JSON-RPC requests not created by us. + */ +export interface JsonRpcRequest { + jsonrpc: "2.0"; + id: number | string; + method: string; + params?: unknown[] | object; +} + +export interface JsonRpcNotificationRequest { + jsonrpc: "2.0"; + method: string; + params?: unknown[] | object; +} + +export interface SuccessfulJsonRpcResponse { + jsonrpc: "2.0"; + id: number | string; + result: unknown; +} + +export interface FailedJsonRpcResponse { + jsonrpc: "2.0"; + id: number | string | null; + error: { + code: number; + message: string; + data?: unknown; + }; +} + +export type JsonRpcResponse = SuccessfulJsonRpcResponse | FailedJsonRpcResponse; + +/** + * Gets a JSON-RPC 2.0 request object. + * See https://www.jsonrpc.org/specification#request_object + */ +export function getJsonRpcRequest( + id: number | string, + method: string, + params?: unknown[] | object, +): JsonRpcRequest { + const requestObject: JsonRpcRequest = { + jsonrpc: "2.0", + id, + method, + }; + + if (isObject(params)) { + throw new HardhatError(HardhatError.ERRORS.NETWORK.INVALID_REQUEST_PARAMS); + } + + // We default it as an empty array to be conservative + requestObject.params = params ?? []; + + if (id !== undefined) { + requestObject.id = id; + } + + return requestObject; +} + +export function parseJsonRpcResponse( + text: string, +): JsonRpcResponse | JsonRpcResponse[] { + try { + const json: unknown = JSON.parse(text); + + if (Array.isArray(json)) { + if (json.every(isJsonRpcResponse)) { + return json; + } + } else if (isJsonRpcResponse(json)) { + return json; + } + + /* eslint-disable-next-line no-restricted-syntax -- allow throwing a + generic error here as it will be handled in the catch block */ + throw new Error(); + } catch { + throw new HardhatError(HardhatError.ERRORS.NETWORK.INVALID_JSON_RESPONSE, { + response: text, + }); + } +} + +export function isJsonRpcResponse( + payload: unknown, +): payload is JsonRpcResponse { + if (!isObject(payload)) { + return false; + } + + if (payload.jsonrpc !== "2.0") { + return false; + } + + if ( + typeof payload.id !== "number" && + typeof payload.id !== "string" && + payload.id !== null + ) { + return false; + } + + if (payload.result === undefined && payload.error === undefined) { + return false; + } + + if (payload.error !== undefined) { + if (!isObject(payload.error)) { + return false; + } + + if (typeof payload.error.code !== "number") { + return false; + } + + if (typeof payload.error.message !== "string") { + return false; + } + } + + return true; +} + +export function isFailedJsonRpcResponse( + payload: JsonRpcResponse, +): payload is FailedJsonRpcResponse { + return "error" in payload && payload.error !== undefined; +} diff --git a/v-next/hardhat/src/internal/utils/package.ts b/v-next/hardhat/src/internal/utils/package.ts index 9a852cdbad..684cfb381c 100644 --- a/v-next/hardhat/src/internal/utils/package.ts +++ b/v-next/hardhat/src/internal/utils/package.ts @@ -2,10 +2,18 @@ import type { PackageJson } from "@ignored/hardhat-vnext-utils/package"; import { readClosestPackageJson } from "@ignored/hardhat-vnext-utils/package"; +let cachedHardhatVersion: string | undefined; + export async function getHardhatVersion(): Promise { + if (cachedHardhatVersion !== undefined) { + return cachedHardhatVersion; + } + const packageJson: PackageJson = await readClosestPackageJson( import.meta.url, ); + cachedHardhatVersion = packageJson.version; + return packageJson.version; } diff --git a/v-next/hardhat/src/types/providers.ts b/v-next/hardhat/src/types/providers.ts new file mode 100644 index 0000000000..e21679e9bf --- /dev/null +++ b/v-next/hardhat/src/types/providers.ts @@ -0,0 +1,80 @@ +import type { + JsonRpcRequest, + JsonRpcResponse, +} from "../internal/network/utils/json-rpc.js"; +import type EventEmitter from "node:events"; + +export interface RequestArguments { + readonly method: string; + readonly params?: readonly unknown[] | object; +} + +export interface ProviderRpcError extends Error { + code: number; + data?: unknown; +} + +export interface EIP1193Provider extends EventEmitter { + /** + * Sends a JSON-RPC request. + * + * @param requestArguments The arguments for the request. The first argument + * should be a string representing the method name, and the second argument + * should be an array containing the parameters. See {@link RequestArguments}. + * @returns The `result` property of the successful JSON-RPC response. See + * {@link JsonRpcResponse}. + * @throws {ProviderError} If the JSON-RPC response indicates a failure. + * @throws {HardhatError} with descriptor: + * - {@link HardhatError.ERRORS.NETWORK.INVALID_REQUEST_PARAMS} if the + * params are not an array. + * - {@link HardhatError.ERRORS.NETWORK.CONNECTION_REFUSED} if the + * connection is refused. + * - {@link HardhatError.ERRORS.NETWORK.NETWORK_TIMEOUT} if the request + * times out. + */ + request(requestArguments: RequestArguments): Promise; +} + +/** + * The interface used by the HttpProvider to send JSON-RPC requests. + * This interface is an extension of the EIP1193Provider interface, + * adding the `send` and `sendAsync` methods for backwards compatibility. + */ +export interface EthereumProvider extends EIP1193Provider { + /** + * @deprecated + * Sends a JSON-RPC request. This method is present for backwards compatibility + * with the Legacy Provider API. Prefer using {@link request} instead. + * + * @param method The method name for the JSON-RPC request. + * @param params The parameters for the JSON-RPC request. This should be an + * array of values. + * @returns The `result` property of the successful JSON-RPC response. See + * {@link JsonRpcResponse}. + * @throws {ProviderError} If the JSON-RPC response indicates a failure. + * @throws {HardhatError} with descriptor: + * - {@link HardhatError.ERRORS.NETWORK.INVALID_REQUEST_PARAMS} if the + * params are not an array. + * - {@link HardhatError.ERRORS.NETWORK.CONNECTION_REFUSED} if the + * connection is refused. + * - {@link HardhatError.ERRORS.NETWORK.NETWORK_TIMEOUT} if the request + * times out. + */ + send(method: string, params?: unknown[]): Promise; + + /** + * @deprecated + * Sends a JSON-RPC request asynchronously. This method is present for + * backwards compatibility with the Legacy Provider API. Prefer using + * {@link request} instead. + * + * @param jsonRpcRequest The JSON-RPC request object. + * @param callback The callback function to handle the response. The first + * argument should be an error object if an error occurred, and the second + * argument should be the JSON-RPC response object. + */ + sendAsync( + jsonRpcRequest: JsonRpcRequest, + callback: (error: any, jsonRpcResponse: JsonRpcResponse) => void, + ): void; +} diff --git a/v-next/hardhat/test/internal/network/http-provider.ts b/v-next/hardhat/test/internal/network/http-provider.ts new file mode 100644 index 0000000000..ed16df4704 --- /dev/null +++ b/v-next/hardhat/test/internal/network/http-provider.ts @@ -0,0 +1,408 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { HardhatError } from "@ignored/hardhat-vnext-errors"; +import { assertRejectsWithHardhatError } from "@nomicfoundation/hardhat-test-utils"; + +import { + HttpProvider, + getHttpDispatcher, +} from "../../../src/internal/network/http-provider.js"; +import { + ProviderError, + LimitExceededError, +} from "../../../src/internal/network/provider-errors.js"; +import { createTestEnvManager, initializeTestDispatcher } from "../../utils.js"; + +describe("http-provider", () => { + describe("HttpProvider.create", () => { + it("should create an HttpProvider", async () => { + const provider = await HttpProvider.create({ + url: "http://example.com", + networkName: "exampleNetwork", + timeout: 20_000, + }); + + assert.ok(provider instanceof HttpProvider, "Not an HttpProvider"); + }); + + it("should throw if the URL is invalid", async () => { + await assertRejectsWithHardhatError( + HttpProvider.create({ + url: "invalid url", + networkName: "exampleNetwork", + timeout: 20_000, + }), + HardhatError.ERRORS.NETWORK.INVALID_URL, + { value: "invalid url" }, + ); + }); + }); + + /** + * To test the HttpProvider#request method, we need to use an interceptor to + * mock the network requests. As the HttpProvider.create does not allow to + * pass a custom dispatcher, we use the constructor directly. + */ + describe("HttpProvider#request", async () => { + const interceptor = await initializeTestDispatcher(); + const baseInterceptorOptions = { + path: "/", + method: "POST", + }; + + it("should make a request", async () => { + const jsonRpcRequest = { + jsonrpc: "2.0", + id: 1, + method: "eth_chainId", + params: [], + }; + const jsonRpcResponse = { + jsonrpc: "2.0", + id: 1, + result: "0x1", + }; + + interceptor + .intercept({ + ...baseInterceptorOptions, + body: JSON.stringify(jsonRpcRequest), + }) + .reply(200, jsonRpcResponse); + + const provider = new HttpProvider( + "http://localhost", + "exampleNetwork", + {}, + interceptor, + ); + + const result = await provider.request({ + method: "eth_chainId", + }); + + assert.ok(typeof result === "string", "Result is not a string"); + assert.equal(result, "0x1"); + }); + + it("should throw if the params are an object", async () => { + const provider = new HttpProvider( + "http://localhost", + "exampleNetwork", + {}, + interceptor, + ); + + await assertRejectsWithHardhatError( + provider.request({ + method: "eth_chainId", + params: {}, + }), + HardhatError.ERRORS.NETWORK.INVALID_REQUEST_PARAMS, + {}, + ); + }); + + it("should throw if the connection is refused", async () => { + // We don't have a way to simulate a connection refused error with the + // mock agent, so we use a real HttpProvider with localhost to test this + // scenario. + const provider = await HttpProvider.create({ + // Using a high-numbered port to ensure connection refused error + url: "http://localhost:49152", + networkName: "exampleNetwork", + timeout: 20_000, + }); + + await assertRejectsWithHardhatError( + provider.request({ + method: "eth_chainId", + }), + HardhatError.ERRORS.NETWORK.CONNECTION_REFUSED, + { network: "exampleNetwork" }, + ); + }); + + // I'm not sure how to test this one + it.todo("should throw if the request times out", async () => {}); + + it("should retry the request if it fails - retry-after header is set", async () => { + const jsonRpcRequest = { + jsonrpc: "2.0", + id: 1, + method: "eth_chainId", + params: [], + }; + const jsonRpcResponse = { + jsonrpc: "2.0", + id: 1, + result: "0x1", + }; + + interceptor + .intercept({ + ...baseInterceptorOptions, + body: JSON.stringify(jsonRpcRequest), + }) + .defaultReplyHeaders({ "retry-after": "0" }) + .reply(429); + interceptor + .intercept({ + ...baseInterceptorOptions, + body: JSON.stringify(jsonRpcRequest), + }) + .reply(200, jsonRpcResponse); + + const provider = new HttpProvider( + "http://localhost", + "exampleNetwork", + {}, + interceptor, + ); + + const result = await provider.request({ + method: "eth_chainId", + }); + + assert.ok(typeof result === "string", "Result is not a string"); + assert.equal(result, "0x1"); + }); + + it("should retry the request if it fails - retry-after header is not set", async () => { + const jsonRpcRequest = { + jsonrpc: "2.0", + id: 1, + method: "eth_chainId", + params: [], + }; + const jsonRpcResponse = { + jsonrpc: "2.0", + id: 1, + result: "0x1", + }; + + interceptor + .intercept({ + ...baseInterceptorOptions, + body: JSON.stringify(jsonRpcRequest), + }) + .reply(429); + interceptor + .intercept({ + ...baseInterceptorOptions, + body: JSON.stringify(jsonRpcRequest), + }) + .reply(200, jsonRpcResponse); + + const provider = new HttpProvider( + "http://localhost", + "exampleNetwork", + {}, + interceptor, + ); + + const result = await provider.request({ + method: "eth_chainId", + }); + + assert.ok(typeof result === "string", "Result is not a string"); + assert.equal(result, "0x1"); + }); + + it("should throw a ProviderError if the max retries are reached", async () => { + const jsonRpcRequest = { + jsonrpc: "2.0", + id: 1, + method: "eth_chainId", + params: [], + }; + + const retries = 8; // Original request + 7 retries + for (let i = 0; i < retries; i++) { + interceptor + .intercept({ + ...baseInterceptorOptions, + body: JSON.stringify(jsonRpcRequest), + }) + // Remove the retry-after header to test the exponential backoff + // logic, but the test will take a long time to run + .defaultReplyHeaders({ "retry-after": "0" }) + .reply(429); + } + + const provider = new HttpProvider( + "http://localhost", + "exampleNetwork", + {}, + interceptor, + ); + + try { + await provider.request({ + method: "eth_chainId", + }); + } catch (error) { + assert.ok( + ProviderError.isProviderError(error), + "Error is not a ProviderError", + ); + assert.equal(error.code, LimitExceededError.CODE); + return; + } + assert.fail("Function did not throw any error"); + }); + + it("should throw a ProviderError if the retry-after header is too high", async () => { + const jsonRpcRequest = { + jsonrpc: "2.0", + id: 1, + method: "eth_chainId", + params: [], + }; + + interceptor + .intercept({ + ...baseInterceptorOptions, + body: JSON.stringify(jsonRpcRequest), + }) + .defaultReplyHeaders({ "retry-after": "6" }) + .reply(429); + + const provider = new HttpProvider( + "http://localhost", + "exampleNetwork", + {}, + interceptor, + ); + + try { + await provider.request({ + method: "eth_chainId", + }); + } catch (error) { + assert.ok( + ProviderError.isProviderError(error), + "Error is not a ProviderError", + ); + assert.equal(error.code, LimitExceededError.CODE); + return; + } + assert.fail("Function did not throw any error"); + }); + + it("should throw if the response is not a valid JSON-RPC response", async () => { + const jsonRpcRequest = { + jsonrpc: "2.0", + id: 1, + method: "eth_chainId", + params: [], + }; + const invalidResponse = { + invalid: "response", + }; + + interceptor + .intercept({ + ...baseInterceptorOptions, + body: JSON.stringify(jsonRpcRequest), + }) + .reply(200, invalidResponse); + + const provider = new HttpProvider( + "http://localhost", + "exampleNetwork", + {}, + interceptor, + ); + + await assertRejectsWithHardhatError( + provider.request({ + method: "eth_chainId", + }), + HardhatError.ERRORS.NETWORK.INVALID_JSON_RESPONSE, + { + response: JSON.stringify(invalidResponse), + }, + ); + }); + + it("should throw a ProviderError if the response is a failed JSON-RPC response", async () => { + const jsonRpcRequest = { + jsonrpc: "2.0", + id: 1, + method: "eth_chainnId", + params: [], + }; + const jsonRpcResponse = { + jsonrpc: "2.0", + id: 1, + error: { + code: -32601, + message: "The method eth_chainnId does not exist/is not available", + data: { + hostname: "localhost", + method: "eth_chainnId", + }, + }, + }; + + interceptor + .intercept({ + ...baseInterceptorOptions, + body: JSON.stringify(jsonRpcRequest), + }) + .reply(200, jsonRpcResponse); + + const provider = new HttpProvider( + "http://localhost", + "exampleNetwork", + {}, + interceptor, + ); + + try { + await provider.request({ + method: "eth_chainnId", + }); + } catch (error) { + assert.ok( + ProviderError.isProviderError(error), + "Error is not a ProviderError", + ); + assert.equal(error.code, -32601); + assert.deepEqual(error.data, { + hostname: "localhost", + method: "eth_chainnId", + }); + return; + } + assert.fail("Function did not throw any error"); + }); + }); + + describe("getHttpDispatcher", () => { + const { setEnvVar } = createTestEnvManager(); + + it("should return a pool dispatcher if process.env.http_proxy is not set", async () => { + const dispatcher = await getHttpDispatcher("http://example.com"); + + assert.equal(dispatcher.constructor.name, "Pool"); + }); + + it("should return a pool dispatcher if shouldUseProxy is false", async () => { + setEnvVar("http_proxy", "http://proxy.com"); + // shouldUseProxy is false for localhost + const dispatcher = await getHttpDispatcher("http://localhost"); + + assert.equal(dispatcher.constructor.name, "Pool"); + }); + + it("should return a proxy dispatcher if process.env.http_proxy is set and shouldUseProxy is true", async () => { + setEnvVar("http_proxy", "http://proxy.com"); + const dispatcher = await getHttpDispatcher("http://example.com"); + + assert.equal(dispatcher.constructor.name, "ProxyAgent"); + }); + }); +}); diff --git a/v-next/hardhat/test/utils.ts b/v-next/hardhat/test/utils.ts new file mode 100644 index 0000000000..0b91ec941e --- /dev/null +++ b/v-next/hardhat/test/utils.ts @@ -0,0 +1,58 @@ +import type { Interceptable } from "@ignored/hardhat-vnext-utils/request"; + +import { after, afterEach, before } from "node:test"; + +import { getTestDispatcher } from "@ignored/hardhat-vnext-utils/request"; + +export function createTestEnvManager() { + const changes = new Set(); + const originalValues = new Map(); + + afterEach(() => { + // Revert changes to process.env based on the originalValues Map entries + changes.forEach((key) => { + const originalValue = originalValues.get(key); + if (originalValue === undefined) { + delete process.env[key]; + } else { + process.env[key] = originalValue; + } + }); + changes.clear(); + }); + + return { + setEnvVar(name: string, value: string): void { + // Before setting a new value, save the original value if it hasn't been saved yet + if (!changes.has(name)) { + originalValues.set(name, process.env[name]); + changes.add(name); + } + process.env[name] = value; + }, + }; +} + +interface InitializeOptions { + url?: string; + timeout?: number; +} + +export const initializeTestDispatcher = async ( + options: InitializeOptions = {}, +): Promise => { + const { url = "http://localhost", timeout } = options; + + const mockAgent = await getTestDispatcher({ timeout }); + const interceptor = mockAgent.get(url); + + before(() => { + mockAgent.disableNetConnect(); + }); + + after(() => { + mockAgent.enableNetConnect(); + }); + + return interceptor; +};