From 8db5041ab599b5caddff652b8a75e007a823501a Mon Sep 17 00:00:00 2001 From: Luis Schaab Date: Wed, 31 Jul 2024 18:33:13 -0300 Subject: [PATCH 01/17] refactor: remove redundant condition --- v-next/hardhat-errors/src/errors.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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; } From ab89e63ad03ac9ffa35813ff4b47937482cd7345 Mon Sep 17 00:00:00 2001 From: Luis Schaab Date: Wed, 31 Jul 2024 18:37:10 -0300 Subject: [PATCH 02/17] refactor: define Dispatcher type to abstract Undici types - Created a Dispatcher type alias for UndiciT.Dispatcher. - This change ensures that Undici types are not directly exposed in getDispatcher. - In the future, we may narrow down the Dispatcher type to include only the required properties. --- v-next/hardhat-utils/src/request.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/v-next/hardhat-utils/src/request.ts b/v-next/hardhat-utils/src/request.ts index e535a569d4..fab44e0c95 100644 --- a/v-next/hardhat-utils/src/request.ts +++ b/v-next/hardhat-utils/src/request.ts @@ -27,6 +27,8 @@ export const DEFAULT_MAX_REDIRECTS = 10; export const DEFAULT_POOL_MAX_CONNECTIONS = 128; export const DEFAULT_USER_AGENT = "Hardhat"; +export type Dispatcher = UndiciT.Dispatcher; + /** * Options to configure the dispatcher. * @@ -228,7 +230,7 @@ export async function getDispatcher( maxConnections, isTestDispatcher, }: DispatcherOptions = {}, -): Promise { +): Promise { try { if (pool !== undefined && proxy !== undefined) { throw new Error( From 739bb47c80d01b5892e9c1d971c087d253cd1ebe Mon Sep 17 00:00:00 2001 From: Luis Schaab Date: Wed, 31 Jul 2024 18:57:01 -0300 Subject: [PATCH 03/17] refactor: copy getHardhatVersion to core and add caching - Copied the getHardhatVersion helper from the hardhat package to the core package. - Added a caching mechanism to store the hardhat version once obtained, improving performance. - The future structure of the packages is still uncertain: we might have two different packages each with its own version, different packages with mirrored versions, or a single combined package for core and hardhat. - Currently, the helper is duplicated, which may be sufficient for now but might need to be revisited in the future. --- v-next/core/src/internal/utils/package.ts | 19 +++++++++++++++++++ v-next/hardhat/src/internal/utils/package.ts | 8 ++++++++ 2 files changed, 27 insertions(+) create mode 100644 v-next/core/src/internal/utils/package.ts diff --git a/v-next/core/src/internal/utils/package.ts b/v-next/core/src/internal/utils/package.ts new file mode 100644 index 0000000000..684cfb381c --- /dev/null +++ b/v-next/core/src/internal/utils/package.ts @@ -0,0 +1,19 @@ +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/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; } From 9a3cd25a52f4837ffe55b51bd16afdf625e6b439 Mon Sep 17 00:00:00 2001 From: Luis Schaab Date: Wed, 31 Jul 2024 19:12:03 -0300 Subject: [PATCH 04/17] feat: add isValidUrl util to validate absolute URLs --- v-next/hardhat-utils/src/request.ts | 15 +++++++++++++++ v-next/hardhat-utils/test/request.ts | 21 +++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/v-next/hardhat-utils/src/request.ts b/v-next/hardhat-utils/src/request.ts index fab44e0c95..e61f451474 100644 --- a/v-next/hardhat-utils/src/request.ts +++ b/v-next/hardhat-utils/src/request.ts @@ -282,6 +282,21 @@ 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 { DownloadError, RequestError, diff --git a/v-next/hardhat-utils/test/request.ts b/v-next/hardhat-utils/test/request.ts index 09e96d22c3..a93d324026 100644 --- a/v-next/hardhat-utils/test/request.ts +++ b/v-next/hardhat-utils/test/request.ts @@ -24,6 +24,7 @@ import { download, getDispatcher, shouldUseProxy, + isValidUrl, } from "../src/request.js"; import { useTmpDir } from "./helpers/fs.js"; @@ -606,4 +607,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); + }); + }); }); From 0b99f6ab7f75ef83976e8293f3da1cc267e723b3 Mon Sep 17 00:00:00 2001 From: Luis Schaab Date: Wed, 31 Jul 2024 19:19:39 -0300 Subject: [PATCH 05/17] feat: add delay util to pause execution --- v-next/hardhat-utils/src/lang.ts | 10 ++++++++ v-next/hardhat-utils/test/lang.ts | 39 ++++++++++++++++++++++++++++++- 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/v-next/hardhat-utils/src/lang.ts b/v-next/hardhat-utils/src/lang.ts index 141582364e..5607ec7b8d 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 delay. + * @returns A promise that resolves after the specified delay. + */ +export async function delay(seconds: number): Promise { + await new Promise((resolve) => setTimeout(resolve, seconds * 1000)); +} diff --git a/v-next/hardhat-utils/test/lang.ts b/v-next/hardhat-utils/test/lang.ts index ac4c622700..4d4bc527dc 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, delay } from "../src/lang.js"; describe("lang", () => { describe("deepClone", () => { @@ -388,4 +388,41 @@ describe("lang", () => { ); }); }); + + describe("delay", () => { + it("should wait for the specified time", async () => { + const start = Date.now(); + await delay(1); + const end = Date.now(); + + assert.ok(end - start >= 1000, "delay did not wait for 1 second"); + }); + + it("should handle zero delay", async () => { + const start = Date.now(); + await delay(0); + const end = Date.now(); + + assert.ok(end - start < 100, "delay did not handle zero delay correctly"); + }); + + it("should handle negative delay", async () => { + const start = Date.now(); + await delay(-1); + const end = Date.now(); + + assert.ok( + end - start < 100, + "delay did not handle negative delay correctly", + ); + }); + + it("should handle non-integer delay", async () => { + const start = Date.now(); + await delay(0.5); + const end = Date.now(); + + assert.ok(end - start >= 500, "delay did not wait for 0.5 seconds"); + }); + }); }); From a2ffb442758d2b3eca0bb59b3181406a7ebe3737 Mon Sep 17 00:00:00 2001 From: Luis Schaab Date: Thu, 1 Aug 2024 21:04:20 -0300 Subject: [PATCH 06/17] feat: Add specific error types to request methods - Added ConnectionRefusedError handling for ECONNREFUSED errors. - Added RequestTimeoutError handling for UND_ERR_CONNECT_TIMEOUT, UND_ERR_HEADERS_TIMEOUT, and UND_ERR_BODY_TIMEOUT errors. --- v-next/hardhat-utils/src/errors/request.ts | 12 ++++ v-next/hardhat-utils/src/request.ts | 80 +++++++++++++++++++--- 2 files changed, 84 insertions(+), 8 deletions(-) diff --git a/v-next/hardhat-utils/src/errors/request.ts b/v-next/hardhat-utils/src/errors/request.ts index 499916744e..feb90f1e68 100644 --- a/v-next/hardhat-utils/src/errors/request.ts +++ b/v-next/hardhat-utils/src/errors/request.ts @@ -20,3 +20,15 @@ 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); + } +} diff --git a/v-next/hardhat-utils/src/request.ts b/v-next/hardhat-utils/src/request.ts index e61f451474..2177011e40 100644 --- a/v-next/hardhat-utils/src/request.ts +++ b/v-next/hardhat-utils/src/request.ts @@ -11,6 +11,8 @@ import { DownloadError, RequestError, DispatcherError, + RequestTimeoutError, + ConnectionRefusedError, } from "./errors/request.js"; import { move } from "./fs.js"; import { @@ -66,7 +68,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, @@ -86,7 +90,20 @@ export async function getRequest( ...baseRequestOptions, }); } catch (e) { - ensureError(e); + ensureError(e); + + if (e.code === "ECONNREFUSED") { + throw new ConnectionRefusedError(url, e); + } + + if ( + e.code === "UND_ERR_CONNECT_TIMEOUT" || + e.code === "UND_ERR_HEADERS_TIMEOUT" || + e.code === "UND_ERR_BODY_TIMEOUT" + ) { + throw new RequestTimeoutError(url, e); + } + throw new RequestError(url, "GET", e); } } @@ -99,7 +116,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, @@ -125,7 +144,20 @@ export async function postJsonRequest( body: JSON.stringify(body), }); } catch (e) { - ensureError(e); + ensureError(e); + + if (e.code === "ECONNREFUSED") { + throw new ConnectionRefusedError(url, e); + } + + if ( + e.code === "UND_ERR_CONNECT_TIMEOUT" || + e.code === "UND_ERR_HEADERS_TIMEOUT" || + e.code === "UND_ERR_BODY_TIMEOUT" + ) { + throw new RequestTimeoutError(url, e); + } + throw new RequestError(url, "POST", e); } } @@ -138,7 +170,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, @@ -165,7 +199,20 @@ export async function postFormRequest( body: querystring.stringify(body as ParsedUrlQueryInput), }); } catch (e) { - ensureError(e); + ensureError(e); + + if (e.code === "ECONNREFUSED") { + throw new ConnectionRefusedError(url, e); + } + + if ( + e.code === "UND_ERR_CONNECT_TIMEOUT" || + e.code === "UND_ERR_HEADERS_TIMEOUT" || + e.code === "UND_ERR_BODY_TIMEOUT" + ) { + throw new RequestTimeoutError(url, e); + } + throw new RequestError(url, "POST", e); } } @@ -177,7 +224,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, @@ -205,7 +254,20 @@ export async function download( await stream.pipeline(body, fileStream); await move(tempFilePath, destination); } catch (e) { - ensureError(e); + ensureError(e); + + if (e.code === "ECONNREFUSED") { + throw new ConnectionRefusedError(url, e); + } + + if ( + e.code === "UND_ERR_CONNECT_TIMEOUT" || + e.code === "UND_ERR_HEADERS_TIMEOUT" || + e.code === "UND_ERR_BODY_TIMEOUT" + ) { + throw new RequestTimeoutError(url, e); + } + throw new DownloadError(url, e); } } @@ -301,4 +363,6 @@ export { DownloadError, RequestError, DispatcherError, + RequestTimeoutError, + ConnectionRefusedError, } from "./errors/request.js"; From d4d8fe459832147dca6c6932a9825f5b0840f306 Mon Sep 17 00:00:00 2001 From: Luis Schaab Date: Mon, 5 Aug 2024 11:11:01 -0300 Subject: [PATCH 07/17] Refactor: add getTestDispatcher and update tests to use initializeTestDispatcher - Added getTestDispatcher function to obtain a MockAgent from undici. - Refactored test code to use a new helper function initializeTestDispatcher, which sets up the test dispatcher and returns an interceptor. - Renamed mockPool to interceptor in the test file for clarity. --- v-next/hardhat-utils/src/request.ts | 13 ++ v-next/hardhat-utils/test/helpers/request.ts | 29 ++-- v-next/hardhat-utils/test/request.ts | 145 +++++++++---------- 3 files changed, 91 insertions(+), 96 deletions(-) diff --git a/v-next/hardhat-utils/src/request.ts b/v-next/hardhat-utils/src/request.ts index 2177011e40..c9a4a7dd48 100644 --- a/v-next/hardhat-utils/src/request.ts +++ b/v-next/hardhat-utils/src/request.ts @@ -30,6 +30,8 @@ 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. @@ -319,6 +321,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. * 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/request.ts b/v-next/hardhat-utils/test/request.ts index a93d324026..07f563fcec 100644 --- a/v-next/hardhat-utils/test/request.ts +++ b/v-next/hardhat-utils/test/request.ts @@ -28,62 +28,50 @@ import { } 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"); }); @@ -215,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"); }); @@ -248,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", @@ -260,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); @@ -273,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); @@ -287,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); @@ -302,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(); @@ -319,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), { + await assert.rejects(getRequest(url, undefined, interceptor), { name: "RequestError", message: `Failed to make GET request to ${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: "/", @@ -345,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); @@ -357,7 +342,7 @@ describe("Requests util", () => { const queryParams = { baz: "qux", }; - mockPool + interceptor .intercept({ ...baseInterceptorOptions, query: queryParams, @@ -367,7 +352,7 @@ describe("Requests util", () => { url, body, { queryParams }, - mockPool, + interceptor, ); assert.notEqual(response, undefined, "Should return a response"); @@ -379,7 +364,7 @@ describe("Requests util", () => { const extraHeaders = { "X-Custom-Header": "value", }; - mockPool + interceptor .intercept({ ...baseInterceptorOptions, headers: { ...baseInterceptorOptions.headers, ...extraHeaders }, @@ -389,7 +374,7 @@ describe("Requests util", () => { url, body, { extraHeaders }, - mockPool, + interceptor, ); assert.notEqual(response, undefined, "Should return a response"); @@ -399,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(); @@ -417,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), { + await assert.rejects(postJsonRequest(url, body, undefined, interceptor), { name: "RequestError", message: `Failed to make POST request to ${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: "/", @@ -443,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); @@ -455,7 +440,7 @@ describe("Requests util", () => { const queryParams = { baz: "qux", }; - mockPool + interceptor .intercept({ ...baseInterceptorOptions, query: queryParams, @@ -465,7 +450,7 @@ describe("Requests util", () => { url, body, { queryParams }, - mockPool, + interceptor, ); assert.notEqual(response, undefined, "Should return a response"); @@ -477,7 +462,7 @@ describe("Requests util", () => { const extraHeaders = { "X-Custom-Header": "value", }; - mockPool + interceptor .intercept({ ...baseInterceptorOptions, headers: { ...baseInterceptorOptions.headers, ...extraHeaders }, @@ -487,7 +472,7 @@ describe("Requests util", () => { url, body, { extraHeaders }, - mockPool, + interceptor, ); assert.notEqual(response, undefined, "Should return a response"); @@ -497,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(); @@ -515,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), { + await assert.rejects(postFormRequest(url, body, undefined, interceptor), { name: "RequestError", message: `Failed to make POST request to ${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", @@ -540,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"); @@ -549,11 +534,11 @@ 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), { + await assert.rejects(download(url, destination, undefined, interceptor), { name: "DownloadError", message: `Failed to download file from ${url}`, }); From 87d0f9b8ae1b3598d229fbd49c4c974db47b2c8c Mon Sep 17 00:00:00 2001 From: Luis Schaab Date: Tue, 6 Aug 2024 00:28:15 -0300 Subject: [PATCH 08/17] feat: Add ResponseStatusCodeError for handling 4xx and 5xx errors - Introduced a new error type ResponseStatusCodeError to handle 4xx and 5xx HTTP response status codes. This is needed because we set throwOnError to true in all requests. - ResponseStatusCodeError includes `statusCode`, `headers`, and `body` properties. --- v-next/hardhat-utils/src/errors/request.ts | 16 ++++++++++++ v-next/hardhat-utils/src/internal/request.ts | 2 +- v-next/hardhat-utils/src/request.ts | 26 ++++++++++++++++++-- 3 files changed, 41 insertions(+), 3 deletions(-) diff --git a/v-next/hardhat-utils/src/errors/request.ts b/v-next/hardhat-utils/src/errors/request.ts index feb90f1e68..8395a7ea9b 100644 --- a/v-next/hardhat-utils/src/errors/request.ts +++ b/v-next/hardhat-utils/src/errors/request.ts @@ -32,3 +32,19 @@ export class ConnectionRefusedError extends CustomError { 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: UndiciT.errors.ResponseStatusCodeError) { + super(`Received an unexpected status code from ${sanitizeUrl(url)}`, cause); + this.statusCode = cause.statusCode; + this.headers = cause.headers; + this.body = cause.body; + } +} diff --git a/v-next/hardhat-utils/src/internal/request.ts b/v-next/hardhat-utils/src/internal/request.ts index 0cc2491f21..1fb8524678 100644 --- a/v-next/hardhat-utils/src/internal/request.ts +++ b/v-next/hardhat-utils/src/internal/request.ts @@ -34,7 +34,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 = diff --git a/v-next/hardhat-utils/src/request.ts b/v-next/hardhat-utils/src/request.ts index c9a4a7dd48..23d3f06547 100644 --- a/v-next/hardhat-utils/src/request.ts +++ b/v-next/hardhat-utils/src/request.ts @@ -13,6 +13,7 @@ import { DispatcherError, RequestTimeoutError, ConnectionRefusedError, + ResponseStatusCodeError, } from "./errors/request.js"; import { move } from "./fs.js"; import { @@ -106,6 +107,11 @@ export async function getRequest( throw new RequestTimeoutError(url, e); } + if (e.code === "UND_ERR_RESPONSE_STATUS_CODE") { + ensureError(e); + throw new ResponseStatusCodeError(url, e); + } + throw new RequestError(url, "GET", e); } } @@ -160,6 +166,11 @@ export async function postJsonRequest( throw new RequestTimeoutError(url, e); } + if (e.code === "UND_ERR_RESPONSE_STATUS_CODE") { + ensureError(e); + throw new ResponseStatusCodeError(url, e); + } + throw new RequestError(url, "POST", e); } } @@ -215,6 +226,11 @@ export async function postFormRequest( throw new RequestTimeoutError(url, e); } + if (e.code === "UND_ERR_RESPONSE_STATUS_CODE") { + ensureError(e); + throw new ResponseStatusCodeError(url, e); + } + throw new RequestError(url, "POST", e); } } @@ -270,6 +286,11 @@ export async function download( throw new RequestTimeoutError(url, e); } + if (e.code === "UND_ERR_RESPONSE_STATUS_CODE") { + ensureError(e); + throw new ResponseStatusCodeError(url, e); + } + throw new DownloadError(url, e); } } @@ -373,9 +394,10 @@ export function isValidUrl(url: string): boolean { } export { + ConnectionRefusedError, + DispatcherError, DownloadError, RequestError, - DispatcherError, RequestTimeoutError, - ConnectionRefusedError, + ResponseStatusCodeError, } from "./errors/request.js"; From bdaaad1459efcacc0ac4bb1220b96aa26a961c6a Mon Sep 17 00:00:00 2001 From: Luis Schaab Date: Tue, 6 Aug 2024 12:31:49 -0300 Subject: [PATCH 09/17] feat: Port HTTP provider - Ported existing HTTP provider functionality to v-next. - Updated code to support ESM, follow modern TypeScript practices, and use hardhat-utils. - Added tests for request and sendBatch. --- v-next/core/src/internal/providers/errors.ts | 57 ++ v-next/core/src/internal/providers/http.ts | 286 +++++++++ v-next/core/src/internal/utils/json-rpc.ts | 138 +++++ v-next/core/src/types/providers.ts | 15 + v-next/core/test/internal/providers/http.ts | 597 +++++++++++++++++++ v-next/core/test/utils.ts | 30 +- v-next/hardhat-errors/src/descriptors.ts | 45 ++ 7 files changed, 1167 insertions(+), 1 deletion(-) create mode 100644 v-next/core/src/internal/providers/errors.ts create mode 100644 v-next/core/src/internal/providers/http.ts create mode 100644 v-next/core/src/internal/utils/json-rpc.ts create mode 100644 v-next/core/src/types/providers.ts create mode 100644 v-next/core/test/internal/providers/http.ts diff --git a/v-next/core/src/internal/providers/errors.ts b/v-next/core/src/internal/providers/errors.ts new file mode 100644 index 0000000000..59d59a72e7 --- /dev/null +++ b/v-next/core/src/internal/providers/errors.ts @@ -0,0 +1,57 @@ +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"; + +/** + * The error codes that a provider can return. + * See https://eips.ethereum.org/EIPS/eip-1474#error-codes + */ +export enum ProviderErrorCode { + LIMIT_EXCEEDED = -32005, + INVALID_PARAMS = -32602, +} + +type ProviderErrorMessages = { + [key in ProviderErrorCode]: string; +}; + +/** + * The error messages associated with each error code. + */ +const ProviderErrorMessage: ProviderErrorMessages = { + [ProviderErrorCode.LIMIT_EXCEEDED]: "Request exceeds defined limit", + [ProviderErrorCode.INVALID_PARAMS]: "Invalid method parameters", +}; + +export class ProviderError extends CustomError implements ProviderRpcError { + public code: number; + public data?: unknown; + + constructor(code: ProviderErrorCode, parentError?: Error) { + super(ProviderErrorMessage[code], 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; + } +} diff --git a/v-next/core/src/internal/providers/http.ts b/v-next/core/src/internal/providers/http.ts new file mode 100644 index 0000000000..058a50d11b --- /dev/null +++ b/v-next/core/src/internal/providers/http.ts @@ -0,0 +1,286 @@ +import type { + EIP1193Provider, + RequestArguments, +} from "../../types/providers.js"; +import type { + JsonRpcResponse, + JsonRpcRequest, + SuccessfulJsonRpcResponse, +} from "../utils/json-rpc.js"; +import type { + Dispatcher, + RequestOptions, +} from "@ignored/hardhat-vnext-utils/request"; + +import EventEmitter from "node:events"; + +import { HardhatError } from "@ignored/hardhat-vnext-errors"; +import { delay, isObject } from "@ignored/hardhat-vnext-utils/lang"; +import { + getDispatcher, + isValidUrl, + postJsonRequest, + shouldUseProxy, + ConnectionRefusedError, + RequestTimeoutError, + ResponseStatusCodeError, +} from "@ignored/hardhat-vnext-utils/request"; + +import { + getJsonRpcRequest, + isFailedJsonRpcResponse, + parseJsonRpcResponse, +} from "../utils/json-rpc.js"; +import { getHardhatVersion } from "../utils/package.js"; + +import { ProviderError, ProviderErrorCode } from "./errors.js"; + +const TOO_MANY_REQUEST_STATUS = 429; +const MAX_RETRIES = 6; +const MAX_RETRY_WAIT_TIME_SECONDS = 5; + +export class HttpProvider extends EventEmitter implements EIP1193Provider { + readonly #url: string; + readonly #networkName: string; + readonly #extraHeaders: Record; + readonly #dispatcher: Dispatcher; + #nextRequestId = 1; + + /** + * Creates a new instance of `HttpProvider`. + * + * @param url + * @param networkName + * @param extraHeaders + * @param timeout + * @returns + */ + public static async create( + 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({ method, params }: RequestArguments): Promise { + const jsonRpcRequest = getJsonRpcRequest( + this.#nextRequestId++, + method, + params, + ); + const jsonRpcResponse = await this.#fetchJsonRpcResponse(jsonRpcRequest); + + if (isFailedJsonRpcResponse(jsonRpcResponse)) { + const error = new ProviderError(jsonRpcResponse.error.code); + error.data = jsonRpcResponse.error.data; + + // eslint-disable-next-line no-restricted-syntax -- allow throwing ProviderError + throw error; + } + + return jsonRpcResponse.result; + } + + public async sendBatch(batch: RequestArguments[]): Promise { + const requests = batch.map(({ method, params }) => + getJsonRpcRequest(this.#nextRequestId++, method, params), + ); + + const jsonRpcResponses = await this.#fetchJsonRpcResponse(requests); + + const successfulJsonRpcResponses: SuccessfulJsonRpcResponse[] = []; + for (const response of jsonRpcResponses) { + if (isFailedJsonRpcResponse(response)) { + const error = new ProviderError(response.error.code); + error.data = response.error.data; + + // eslint-disable-next-line no-restricted-syntax -- allow throwing ProviderError + throw error; + } else { + successfulJsonRpcResponses.push(response); + } + } + + const sortedResponses = successfulJsonRpcResponses.sort((a, b) => + `${a.id}`.localeCompare(`${b.id}`, undefined, { numeric: true }), + ); + + return sortedResponses; + } + + 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); + } + + const error = new ProviderError(ProviderErrorCode.LIMIT_EXCEEDED); + error.data = { + hostname: new URL(this.#url).hostname, + retryAfterSeconds, + }; + + // eslint-disable-next-line no-restricted-syntax -- allow throwing ProviderError + throw error; + } + + 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 delay(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/core/src/internal/utils/json-rpc.ts b/v-next/core/src/internal/utils/json-rpc.ts new file mode 100644 index 0000000000..fca96158ea --- /dev/null +++ b/v-next/core/src/internal/utils/json-rpc.ts @@ -0,0 +1,138 @@ +import { HardhatError } from "@ignored/hardhat-vnext-errors"; +import { isObject } from "@ignored/hardhat-vnext-utils/lang"; + +/** + * The JSON-RPC 2.0 request object. Technically, the id field is not needed + * if the request is a notification, but we require it here and use a different + * interface for notifications. + */ +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_PARAMS); + } + + if (params !== undefined) { + 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/core/src/types/providers.ts b/v-next/core/src/types/providers.ts new file mode 100644 index 0000000000..69060e0158 --- /dev/null +++ b/v-next/core/src/types/providers.ts @@ -0,0 +1,15 @@ +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 { + request(args: RequestArguments): Promise; +} diff --git a/v-next/core/test/internal/providers/http.ts b/v-next/core/test/internal/providers/http.ts new file mode 100644 index 0000000000..bc040771f3 --- /dev/null +++ b/v-next/core/test/internal/providers/http.ts @@ -0,0 +1,597 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { HardhatError } from "@ignored/hardhat-vnext-errors"; +import { isObject } from "@ignored/hardhat-vnext-utils/lang"; +import { assertRejectsWithHardhatError } from "@nomicfoundation/hardhat-test-utils"; + +import { + ProviderError, + ProviderErrorCode, +} from "../../../src/internal/providers/errors.js"; +import { + HttpProvider, + getHttpDispatcher, +} from "../../../src/internal/providers/http.js"; +import { createTestEnvManager, initializeTestDispatcher } from "../../utils.js"; + +describe("http", () => { + describe("HttpProvider.create", () => { + it("should create an HttpProvider", async () => { + const provider = await HttpProvider.create( + "http://example.com", + "exampleNetwork", + ); + + assert.ok(provider instanceof HttpProvider, "Not an HttpProvider"); + }); + + it("should throw if the URL is invalid", async () => { + await assertRejectsWithHardhatError( + HttpProvider.create("invalid url", "exampleNetwork"), + 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", + }; + 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_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 + "http://localhost:49152", + "exampleNetwork", + ); + + 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", + }; + 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", + }; + 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", + }; + + 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, ProviderErrorCode.LIMIT_EXCEEDED); + assert.deepEqual(error.data, { + hostname: "localhost", + retryAfterSeconds: 0, + }); + 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", + }; + + 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, ProviderErrorCode.LIMIT_EXCEEDED); + assert.deepEqual(error.data, { + hostname: "localhost", + retryAfterSeconds: 6, + }); + 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", + }; + 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", + }; + 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("HttpProvider#sendBatch", async () => { + const interceptor = await initializeTestDispatcher(); + const baseInterceptorOptions = { + path: "/", + method: "POST", + }; + + it("should make a batch request", async () => { + const jsonRpcRequests = [ + { + jsonrpc: "2.0", + id: 1, + method: "eth_chainId", + }, + { + jsonrpc: "2.0", + id: 2, + method: "eth_getCode", + params: ["0x1234", "latest"], + }, + ]; + // The node may return the responses in a different order + const jsonRpcResponses = [ + { + jsonrpc: "2.0", + id: 2, + result: "0x5678", + }, + { + jsonrpc: "2.0", + id: 1, + result: "0x1", + }, + ]; + + interceptor + .intercept({ + ...baseInterceptorOptions, + body: JSON.stringify(jsonRpcRequests), + }) + .reply(200, jsonRpcResponses); + + const provider = new HttpProvider( + "http://localhost", + "exampleNetwork", + {}, + interceptor, + ); + + const response = await provider.sendBatch([ + { method: "eth_chainId" }, + { method: "eth_getCode", params: ["0x1234", "latest"] }, + ]); + + assert.ok(Array.isArray(response), "Response is not an array"); + assert.equal(response.length, 2); + const [ethChainIdResponse, ethGetCodeResponse] = response; + assert.ok( + isObject(ethChainIdResponse), + "ethChainIdResponse is not an object", + ); + assert.ok( + isObject(ethGetCodeResponse), + "ethGetCodeResponse is not an object", + ); + // Responses will be sorted by the provider in ascending order by id + // to match the order of the requests + assert.equal(ethChainIdResponse.id, 1); + assert.equal(ethGetCodeResponse.id, 2); + assert.equal(ethChainIdResponse.result, "0x1"); + assert.equal(ethGetCodeResponse.result, "0x5678"); + }); + + it("should throw if the response is not a valid JSON-RPC response", async () => { + const jsonRpcRequests = [ + { + jsonrpc: "2.0", + id: 1, + method: "eth_chainId", + }, + { + jsonrpc: "2.0", + id: 2, + method: "eth_getCode", + params: ["0x1234", "latest"], + }, + ]; + const jsonRpcResponses = [ + { + jsonrpc: "2.0", + id: 2, + result: "0x5678", + }, + { + invalid: "response", + }, + ]; + + interceptor + .intercept({ + ...baseInterceptorOptions, + body: JSON.stringify(jsonRpcRequests), + }) + .reply(200, jsonRpcResponses); + + const provider = new HttpProvider( + "http://localhost", + "exampleNetwork", + {}, + interceptor, + ); + + await assertRejectsWithHardhatError( + provider.sendBatch([ + { method: "eth_chainId" }, + { method: "eth_getCode", params: ["0x1234", "latest"] }, + ]), + HardhatError.ERRORS.NETWORK.INVALID_JSON_RESPONSE, + { + response: JSON.stringify(jsonRpcResponses), + }, + ); + }); + + it("should throw a ProviderError if the response is a failed JSON-RPC response", async () => { + const jsonRpcRequests = [ + { + jsonrpc: "2.0", + id: 1, + method: "eth_chainId", + }, + { + jsonrpc: "2.0", + id: 2, + method: "eth_getCode", + params: ["0x1234", "latest"], + }, + ]; + const jsonRpcResponses = [ + { + 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", + }, + }, + }, + { + jsonrpc: "2.0", + id: 2, + result: "0x5678", + }, + ]; + + interceptor + .intercept({ + ...baseInterceptorOptions, + body: JSON.stringify(jsonRpcRequests), + }) + .reply(200, jsonRpcResponses); + + const provider = new HttpProvider( + "http://localhost", + "exampleNetwork", + {}, + interceptor, + ); + + try { + await provider.sendBatch([ + { method: "eth_chainId" }, + { method: "eth_getCode", params: ["0x1234", "latest"] }, + ]); + } 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/core/test/utils.ts b/v-next/core/test/utils.ts index ef80e5744c..0b91ec941e 100644 --- a/v-next/core/test/utils.ts +++ b/v-next/core/test/utils.ts @@ -1,4 +1,8 @@ -import { afterEach } from "node:test"; +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(); @@ -28,3 +32,27 @@ export function createTestEnvManager() { }, }; } + +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; +}; diff --git a/v-next/hardhat-errors/src/descriptors.ts b/v-next/hardhat-errors/src/descriptors.ts index 38d9ab44c2..ff8ee3b18f 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_PARAMS: { + number: 701, + messageTemplate: + "Invalid method parameters. Only array parameters are supported.", + websiteTitle: "Invalid method parameters", + websiteDescription: + "You are trying to make an EIP-1193 request with object parameters, but only array parameters are supported.", + }, + 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; From 62ba9a37879002218ad8093d354da694f242483d Mon Sep 17 00:00:00 2001 From: Luis Schaab Date: Tue, 6 Aug 2024 13:13:05 -0300 Subject: [PATCH 10/17] refactor: Extract error handling logic to a separate function and fix tests - Moved error handling logic to a handleError function to avoid code duplication. - Updated the error handling to correctly check for e.cause.code or e.code existence. - Fixed tests that were not previously updated to expect ResponseStatusCodeError. --- v-next/hardhat-utils/src/internal/request.ts | 33 +++++++++ v-next/hardhat-utils/src/request.ts | 72 ++------------------ v-next/hardhat-utils/test/request.ts | 16 ++--- 3 files changed, 46 insertions(+), 75 deletions(-) diff --git a/v-next/hardhat-utils/src/internal/request.ts b/v-next/hardhat-utils/src/internal/request.ts index 1fb8524678..baf15cd284 100644 --- a/v-next/hardhat-utils/src/internal/request.ts +++ b/v-next/hardhat-utils/src/internal/request.ts @@ -5,12 +5,16 @@ import type UndiciT from "undici"; import path from "node:path"; import url from "node:url"; +import { ensureError } from "../error.js"; import { mkdir } from "../fs.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 { @@ -135,3 +139,32 @@ 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: NodeJS.ErrnoException, + requestUrl: string, +): void { + let causeCode; + if (e.cause !== undefined) { + ensureError(e.cause); + causeCode = e.cause.code; + } + const errorCode = 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") { + ensureError(e); + throw new ResponseStatusCodeError(requestUrl, e); + } +} diff --git a/v-next/hardhat-utils/src/request.ts b/v-next/hardhat-utils/src/request.ts index 23d3f06547..470944c946 100644 --- a/v-next/hardhat-utils/src/request.ts +++ b/v-next/hardhat-utils/src/request.ts @@ -11,9 +11,6 @@ import { DownloadError, RequestError, DispatcherError, - RequestTimeoutError, - ConnectionRefusedError, - ResponseStatusCodeError, } from "./errors/request.js"; import { move } from "./fs.js"; import { @@ -23,6 +20,7 @@ import { getBasicDispatcher, getPoolDispatcher, getProxyDispatcher, + handleError, } from "./internal/request.js"; export const DEFAULT_TIMEOUT_IN_MILLISECONDS = 30_000; @@ -95,22 +93,7 @@ export async function getRequest( } catch (e) { ensureError(e); - if (e.code === "ECONNREFUSED") { - throw new ConnectionRefusedError(url, e); - } - - if ( - e.code === "UND_ERR_CONNECT_TIMEOUT" || - e.code === "UND_ERR_HEADERS_TIMEOUT" || - e.code === "UND_ERR_BODY_TIMEOUT" - ) { - throw new RequestTimeoutError(url, e); - } - - if (e.code === "UND_ERR_RESPONSE_STATUS_CODE") { - ensureError(e); - throw new ResponseStatusCodeError(url, e); - } + handleError(e, url); throw new RequestError(url, "GET", e); } @@ -154,22 +137,7 @@ export async function postJsonRequest( } catch (e) { ensureError(e); - if (e.code === "ECONNREFUSED") { - throw new ConnectionRefusedError(url, e); - } - - if ( - e.code === "UND_ERR_CONNECT_TIMEOUT" || - e.code === "UND_ERR_HEADERS_TIMEOUT" || - e.code === "UND_ERR_BODY_TIMEOUT" - ) { - throw new RequestTimeoutError(url, e); - } - - if (e.code === "UND_ERR_RESPONSE_STATUS_CODE") { - ensureError(e); - throw new ResponseStatusCodeError(url, e); - } + handleError(e, url); throw new RequestError(url, "POST", e); } @@ -214,22 +182,7 @@ export async function postFormRequest( } catch (e) { ensureError(e); - if (e.code === "ECONNREFUSED") { - throw new ConnectionRefusedError(url, e); - } - - if ( - e.code === "UND_ERR_CONNECT_TIMEOUT" || - e.code === "UND_ERR_HEADERS_TIMEOUT" || - e.code === "UND_ERR_BODY_TIMEOUT" - ) { - throw new RequestTimeoutError(url, e); - } - - if (e.code === "UND_ERR_RESPONSE_STATUS_CODE") { - ensureError(e); - throw new ResponseStatusCodeError(url, e); - } + handleError(e, url); throw new RequestError(url, "POST", e); } @@ -274,22 +227,7 @@ export async function download( } catch (e) { ensureError(e); - if (e.code === "ECONNREFUSED") { - throw new ConnectionRefusedError(url, e); - } - - if ( - e.code === "UND_ERR_CONNECT_TIMEOUT" || - e.code === "UND_ERR_HEADERS_TIMEOUT" || - e.code === "UND_ERR_BODY_TIMEOUT" - ) { - throw new RequestTimeoutError(url, e); - } - - if (e.code === "UND_ERR_RESPONSE_STATUS_CODE") { - ensureError(e); - throw new ResponseStatusCodeError(url, e); - } + handleError(e, url); throw new DownloadError(url, e); } diff --git a/v-next/hardhat-utils/test/request.ts b/v-next/hardhat-utils/test/request.ts index 07f563fcec..b81a31e09e 100644 --- a/v-next/hardhat-utils/test/request.ts +++ b/v-next/hardhat-utils/test/request.ts @@ -309,8 +309,8 @@ describe("Requests util", () => { .reply(500, "Internal Server Error"); await assert.rejects(getRequest(url, undefined, interceptor), { - name: "RequestError", - message: `Failed to make GET request to ${url}`, + name: "ResponseStatusCodeError", + message: `Received an unexpected status code from ${url}`, }); }); }); @@ -407,8 +407,8 @@ describe("Requests util", () => { .reply(500, "Internal Server Error"); await assert.rejects(postJsonRequest(url, body, undefined, interceptor), { - name: "RequestError", - message: `Failed to make POST request to ${url}`, + name: "ResponseStatusCodeError", + message: `Received an unexpected status code from ${url}`, }); }); }); @@ -505,8 +505,8 @@ describe("Requests util", () => { .reply(500, "Internal Server Error"); await assert.rejects(postFormRequest(url, body, undefined, interceptor), { - name: "RequestError", - message: `Failed to make POST request to ${url}`, + name: "ResponseStatusCodeError", + message: `Received an unexpected status code from ${url}`, }); }); }); @@ -539,8 +539,8 @@ describe("Requests util", () => { .reply(500, "Internal Server Error"); await assert.rejects(download(url, destination, undefined, interceptor), { - name: "DownloadError", - message: `Failed to download file from ${url}`, + name: "ResponseStatusCodeError", + message: `Received an unexpected status code from ${url}`, }); }); }); From 9c4d87db4647d7440eef5fdc5eaaa31230265000 Mon Sep 17 00:00:00 2001 From: Luis Schaab Date: Mon, 12 Aug 2024 17:19:37 -0300 Subject: [PATCH 11/17] refactor: apply PR suggestions & add backwards compatibility methods - Added send and sendAsync from the backwards compatibility provider as we're no longer going to use wrappers. - Added jsdoc for the public methods. - Changed the HttpProvider.create signature to receive a config object. timeout is no longer optional in this object. - Default the params to an empty array if not provided. - Renamed delay to sleep. - Do not set error.data on failed retry. --- v-next/core/src/internal/providers/http.ts | 157 +++++++++---- v-next/core/src/internal/utils/json-rpc.ts | 17 +- v-next/core/test/internal/providers/http.ts | 235 ++------------------ v-next/hardhat-errors/src/descriptors.ts | 20 +- v-next/hardhat-utils/src/lang.ts | 6 +- v-next/hardhat-utils/test/lang.ts | 20 +- 6 files changed, 176 insertions(+), 279 deletions(-) diff --git a/v-next/core/src/internal/providers/http.ts b/v-next/core/src/internal/providers/http.ts index 058a50d11b..c3dc333d13 100644 --- a/v-next/core/src/internal/providers/http.ts +++ b/v-next/core/src/internal/providers/http.ts @@ -13,9 +13,11 @@ import type { } from "@ignored/hardhat-vnext-utils/request"; import EventEmitter from "node:events"; +import util from "node:util"; import { HardhatError } from "@ignored/hardhat-vnext-errors"; -import { delay, isObject } from "@ignored/hardhat-vnext-utils/lang"; +import { ensureError } from "@ignored/hardhat-vnext-utils/error"; +import { sleep, isObject } from "@ignored/hardhat-vnext-utils/lang"; import { getDispatcher, isValidUrl, @@ -48,19 +50,18 @@ export class HttpProvider extends EventEmitter implements EIP1193Provider { /** * Creates a new instance of `HttpProvider`. - * - * @param url - * @param networkName - * @param extraHeaders - * @param timeout - * @returns */ - public static async create( - url: string, - networkName: string, - extraHeaders: Record = {}, - timeout?: number, - ): Promise { + 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, @@ -100,7 +101,27 @@ export class HttpProvider extends EventEmitter implements EIP1193Provider { this.#dispatcher = dispatcher; } - public async request({ method, params }: RequestArguments): Promise { + /** + * 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. + * @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. + */ + public async request( + requestArguments: RequestArguments, + ): Promise { + const { method, params } = requestArguments; + const jsonRpcRequest = getJsonRpcRequest( this.#nextRequestId++, method, @@ -116,34 +137,94 @@ export class HttpProvider extends EventEmitter implements EIP1193Provider { throw error; } + // TODO: emit hardhat network events (hardhat_reset, evm_revert) + return jsonRpcResponse.result; } - public async sendBatch(batch: RequestArguments[]): Promise { - const requests = batch.map(({ method, params }) => - getJsonRpcRequest(this.#nextRequestId++, method, params), - ); + /** + * @deprecated + * Sends a JSON-RPC request. This method is present for backwards compatibility + * with the Legacy Provider API. Prefer using `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. + * @throws {ProviderError} If the JSON-RPC response indicates a failure. + * @throws {HardhatError} with descriptor: + * - {@link HardhatError.ERRORS.NETWORK.INVALID_REQUEST_METHOD} if the + * method is not a string. + * - {@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. + */ + public send( + method: string, + params?: unknown[], + ): Promise { + return this.request({ method, params }); + } - const jsonRpcResponses = await this.#fetchJsonRpcResponse(requests); + /** + * @deprecated + * Sends a JSON-RPC request asynchronously. This method is present for + * backwards compatibility with the Legacy Provider API. Prefer using + * `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. + */ + public sendAsync( + jsonRpcRequest: JsonRpcRequest, + callback: (error: any, response: 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); - const successfulJsonRpcResponses: SuccessfulJsonRpcResponse[] = []; - for (const response of jsonRpcResponses) { - if (isFailedJsonRpcResponse(response)) { - const error = new ProviderError(response.error.code); - error.data = response.error.data; + if (error.code === undefined) { + throw error; + } - // eslint-disable-next-line no-restricted-syntax -- allow throwing ProviderError - throw error; - } else { - successfulJsonRpcResponses.push(response); + /* 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, + }, + }, + }; } - } - const sortedResponses = successfulJsonRpcResponses.sort((a, b) => - `${a.id}`.localeCompare(`${b.id}`, undefined, { numeric: true }), - ); + return jsonRpcResponse; + }; - return sortedResponses; + util.callbackify(handleJsonRpcRequest)(callback); } async #fetchJsonRpcResponse( @@ -211,14 +292,8 @@ export class HttpProvider extends EventEmitter implements EIP1193Provider { return this.#retry(jsonRpcRequest, retryAfterSeconds, retryCount); } - const error = new ProviderError(ProviderErrorCode.LIMIT_EXCEEDED); - error.data = { - hostname: new URL(this.#url).hostname, - retryAfterSeconds, - }; - // eslint-disable-next-line no-restricted-syntax -- allow throwing ProviderError - throw error; + throw new ProviderError(ProviderErrorCode.LIMIT_EXCEEDED); } throw e; @@ -257,7 +332,7 @@ export class HttpProvider extends EventEmitter implements EIP1193Provider { retryAfterSeconds: number, retryCount: number, ) { - await delay(retryAfterSeconds); + await sleep(retryAfterSeconds); return this.#fetchJsonRpcResponse(request, retryCount + 1); } } diff --git a/v-next/core/src/internal/utils/json-rpc.ts b/v-next/core/src/internal/utils/json-rpc.ts index fca96158ea..e6bd2a2d48 100644 --- a/v-next/core/src/internal/utils/json-rpc.ts +++ b/v-next/core/src/internal/utils/json-rpc.ts @@ -2,9 +2,13 @@ import { HardhatError } from "@ignored/hardhat-vnext-errors"; import { isObject } from "@ignored/hardhat-vnext-utils/lang"; /** - * The JSON-RPC 2.0 request object. Technically, the id field is not needed - * if the request is a notification, but we require it here and use a different - * interface for notifications. + * 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"; @@ -53,12 +57,11 @@ export function getJsonRpcRequest( }; if (isObject(params)) { - throw new HardhatError(HardhatError.ERRORS.NETWORK.INVALID_PARAMS); + throw new HardhatError(HardhatError.ERRORS.NETWORK.INVALID_REQUEST_PARAMS); } - if (params !== undefined) { - requestObject.params = params; - } + // We default it as an empty array to be conservative + requestObject.params = params ?? []; if (id !== undefined) { requestObject.id = id; diff --git a/v-next/core/test/internal/providers/http.ts b/v-next/core/test/internal/providers/http.ts index bc040771f3..6c16d3f3fb 100644 --- a/v-next/core/test/internal/providers/http.ts +++ b/v-next/core/test/internal/providers/http.ts @@ -2,7 +2,6 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { HardhatError } from "@ignored/hardhat-vnext-errors"; -import { isObject } from "@ignored/hardhat-vnext-utils/lang"; import { assertRejectsWithHardhatError } from "@nomicfoundation/hardhat-test-utils"; import { @@ -18,17 +17,22 @@ import { createTestEnvManager, initializeTestDispatcher } from "../../utils.js"; describe("http", () => { describe("HttpProvider.create", () => { it("should create an HttpProvider", async () => { - const provider = await HttpProvider.create( - "http://example.com", - "exampleNetwork", - ); + 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("invalid url", "exampleNetwork"), + HttpProvider.create({ + url: "invalid url", + networkName: "exampleNetwork", + timeout: 20_000, + }), HardhatError.ERRORS.NETWORK.INVALID_URL, { value: "invalid url" }, ); @@ -52,6 +56,7 @@ describe("http", () => { jsonrpc: "2.0", id: 1, method: "eth_chainId", + params: [], }; const jsonRpcResponse = { jsonrpc: "2.0", @@ -94,7 +99,7 @@ describe("http", () => { method: "eth_chainId", params: {}, }), - HardhatError.ERRORS.NETWORK.INVALID_PARAMS, + HardhatError.ERRORS.NETWORK.INVALID_REQUEST_PARAMS, {}, ); }); @@ -103,11 +108,12 @@ describe("http", () => { // 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( + const provider = await HttpProvider.create({ // Using a high-numbered port to ensure connection refused error - "http://localhost:49152", - "exampleNetwork", - ); + url: "http://localhost:49152", + networkName: "exampleNetwork", + timeout: 20_000, + }); await assertRejectsWithHardhatError( provider.request({ @@ -126,6 +132,7 @@ describe("http", () => { jsonrpc: "2.0", id: 1, method: "eth_chainId", + params: [], }; const jsonRpcResponse = { jsonrpc: "2.0", @@ -167,6 +174,7 @@ describe("http", () => { jsonrpc: "2.0", id: 1, method: "eth_chainId", + params: [], }; const jsonRpcResponse = { jsonrpc: "2.0", @@ -207,6 +215,7 @@ describe("http", () => { jsonrpc: "2.0", id: 1, method: "eth_chainId", + params: [], }; const retries = 8; // Original request + 7 retries @@ -239,10 +248,6 @@ describe("http", () => { "Error is not a ProviderError", ); assert.equal(error.code, ProviderErrorCode.LIMIT_EXCEEDED); - assert.deepEqual(error.data, { - hostname: "localhost", - retryAfterSeconds: 0, - }); return; } assert.fail("Function did not throw any error"); @@ -253,6 +258,7 @@ describe("http", () => { jsonrpc: "2.0", id: 1, method: "eth_chainId", + params: [], }; interceptor @@ -280,10 +286,6 @@ describe("http", () => { "Error is not a ProviderError", ); assert.equal(error.code, ProviderErrorCode.LIMIT_EXCEEDED); - assert.deepEqual(error.data, { - hostname: "localhost", - retryAfterSeconds: 6, - }); return; } assert.fail("Function did not throw any error"); @@ -294,6 +296,7 @@ describe("http", () => { jsonrpc: "2.0", id: 1, method: "eth_chainId", + params: [], }; const invalidResponse = { invalid: "response", @@ -329,6 +332,7 @@ describe("http", () => { jsonrpc: "2.0", id: 1, method: "eth_chainnId", + params: [], }; const jsonRpcResponse = { jsonrpc: "2.0", @@ -377,199 +381,6 @@ describe("http", () => { }); }); - describe("HttpProvider#sendBatch", async () => { - const interceptor = await initializeTestDispatcher(); - const baseInterceptorOptions = { - path: "/", - method: "POST", - }; - - it("should make a batch request", async () => { - const jsonRpcRequests = [ - { - jsonrpc: "2.0", - id: 1, - method: "eth_chainId", - }, - { - jsonrpc: "2.0", - id: 2, - method: "eth_getCode", - params: ["0x1234", "latest"], - }, - ]; - // The node may return the responses in a different order - const jsonRpcResponses = [ - { - jsonrpc: "2.0", - id: 2, - result: "0x5678", - }, - { - jsonrpc: "2.0", - id: 1, - result: "0x1", - }, - ]; - - interceptor - .intercept({ - ...baseInterceptorOptions, - body: JSON.stringify(jsonRpcRequests), - }) - .reply(200, jsonRpcResponses); - - const provider = new HttpProvider( - "http://localhost", - "exampleNetwork", - {}, - interceptor, - ); - - const response = await provider.sendBatch([ - { method: "eth_chainId" }, - { method: "eth_getCode", params: ["0x1234", "latest"] }, - ]); - - assert.ok(Array.isArray(response), "Response is not an array"); - assert.equal(response.length, 2); - const [ethChainIdResponse, ethGetCodeResponse] = response; - assert.ok( - isObject(ethChainIdResponse), - "ethChainIdResponse is not an object", - ); - assert.ok( - isObject(ethGetCodeResponse), - "ethGetCodeResponse is not an object", - ); - // Responses will be sorted by the provider in ascending order by id - // to match the order of the requests - assert.equal(ethChainIdResponse.id, 1); - assert.equal(ethGetCodeResponse.id, 2); - assert.equal(ethChainIdResponse.result, "0x1"); - assert.equal(ethGetCodeResponse.result, "0x5678"); - }); - - it("should throw if the response is not a valid JSON-RPC response", async () => { - const jsonRpcRequests = [ - { - jsonrpc: "2.0", - id: 1, - method: "eth_chainId", - }, - { - jsonrpc: "2.0", - id: 2, - method: "eth_getCode", - params: ["0x1234", "latest"], - }, - ]; - const jsonRpcResponses = [ - { - jsonrpc: "2.0", - id: 2, - result: "0x5678", - }, - { - invalid: "response", - }, - ]; - - interceptor - .intercept({ - ...baseInterceptorOptions, - body: JSON.stringify(jsonRpcRequests), - }) - .reply(200, jsonRpcResponses); - - const provider = new HttpProvider( - "http://localhost", - "exampleNetwork", - {}, - interceptor, - ); - - await assertRejectsWithHardhatError( - provider.sendBatch([ - { method: "eth_chainId" }, - { method: "eth_getCode", params: ["0x1234", "latest"] }, - ]), - HardhatError.ERRORS.NETWORK.INVALID_JSON_RESPONSE, - { - response: JSON.stringify(jsonRpcResponses), - }, - ); - }); - - it("should throw a ProviderError if the response is a failed JSON-RPC response", async () => { - const jsonRpcRequests = [ - { - jsonrpc: "2.0", - id: 1, - method: "eth_chainId", - }, - { - jsonrpc: "2.0", - id: 2, - method: "eth_getCode", - params: ["0x1234", "latest"], - }, - ]; - const jsonRpcResponses = [ - { - 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", - }, - }, - }, - { - jsonrpc: "2.0", - id: 2, - result: "0x5678", - }, - ]; - - interceptor - .intercept({ - ...baseInterceptorOptions, - body: JSON.stringify(jsonRpcRequests), - }) - .reply(200, jsonRpcResponses); - - const provider = new HttpProvider( - "http://localhost", - "exampleNetwork", - {}, - interceptor, - ); - - try { - await provider.sendBatch([ - { method: "eth_chainId" }, - { method: "eth_getCode", params: ["0x1234", "latest"] }, - ]); - } 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(); diff --git a/v-next/hardhat-errors/src/descriptors.ts b/v-next/hardhat-errors/src/descriptors.ts index ff8ee3b18f..5c74bb2330 100644 --- a/v-next/hardhat-errors/src/descriptors.ts +++ b/v-next/hardhat-errors/src/descriptors.ts @@ -483,16 +483,24 @@ Please check Hardhat's output for more details.`, Please check that you are sending a valid URL string for the network or forking \`URL\` parameter.`, }, - INVALID_PARAMS: { + INVALID_REQUEST_METHOD: { number: 701, messageTemplate: - "Invalid method parameters. Only array parameters are supported.", + "Invalid request arguments: the 'method' argument must be a string.", websiteTitle: "Invalid method parameters", websiteDescription: - "You are trying to make an EIP-1193 request with object parameters, but only array parameters are supported.", + "The JSON-RPC request method argument is invalid. The 'method' argument must be a string representing the name of the method to be invoked. Ensure that the 'method' parameter is correctly specified as a string in your JSON-RPC request.", }, - INVALID_JSON_RESPONSE: { + INVALID_REQUEST_PARAMS: { number: 702, + 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: 703, messageTemplate: "Invalid JSON-RPC response received: {response}", websiteTitle: "Invalid JSON-RPC response", websiteDescription: `One of your JSON-RPC requests received an invalid response. @@ -500,7 +508,7 @@ Please check that you are sending a valid URL string for the network or forking Please make sure your node is running, and check your internet connection and networks config.`, }, CONNECTION_REFUSED: { - number: 703, + number: 704, 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", @@ -509,7 +517,7 @@ Please make sure your node is running, and check your internet connection and ne Please make sure your node is running, and check your internet connection and networks config.`, }, NETWORK_TIMEOUT: { - number: 704, + number: 705, messageTemplate: `Network connection timed out. Please check your internet connection and networks config`, websiteTitle: "Network timeout", diff --git a/v-next/hardhat-utils/src/lang.ts b/v-next/hardhat-utils/src/lang.ts index 5607ec7b8d..6874809f04 100644 --- a/v-next/hardhat-utils/src/lang.ts +++ b/v-next/hardhat-utils/src/lang.ts @@ -40,9 +40,9 @@ export function isObject( /** * Pauses the execution for the specified number of seconds. * - * @param seconds The number of seconds to delay. - * @returns A promise that resolves after the specified delay. + * @param seconds The number of seconds to pause the execution. + * @returns A promise that resolves after the specified number of seconds. */ -export async function delay(seconds: number): Promise { +export async function sleep(seconds: number): Promise { await new Promise((resolve) => setTimeout(resolve, seconds * 1000)); } diff --git a/v-next/hardhat-utils/test/lang.ts b/v-next/hardhat-utils/test/lang.ts index 4d4bc527dc..7200c23b07 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, delay } from "../src/lang.js"; +import { deepClone, deepEqual, isObject, sleep } from "../src/lang.js"; describe("lang", () => { describe("deepClone", () => { @@ -389,40 +389,40 @@ describe("lang", () => { }); }); - describe("delay", () => { + describe("sleep", () => { it("should wait for the specified time", async () => { const start = Date.now(); - await delay(1); + await sleep(1); const end = Date.now(); - assert.ok(end - start >= 1000, "delay did not wait for 1 second"); + assert.ok(end - start >= 1000, "sleep did not wait for 1 second"); }); it("should handle zero delay", async () => { const start = Date.now(); - await delay(0); + await sleep(0); const end = Date.now(); - assert.ok(end - start < 100, "delay did not handle zero delay correctly"); + assert.ok(end - start < 100, "sleep did not handle zero delay correctly"); }); it("should handle negative delay", async () => { const start = Date.now(); - await delay(-1); + await sleep(-1); const end = Date.now(); assert.ok( end - start < 100, - "delay did not handle negative delay correctly", + "sleep did not handle negative delay correctly", ); }); it("should handle non-integer delay", async () => { const start = Date.now(); - await delay(0.5); + await sleep(0.5); const end = Date.now(); - assert.ok(end - start >= 500, "delay did not wait for 0.5 seconds"); + assert.ok(end - start >= 500, "sleep did not wait for 0.5 seconds"); }); }); }); From 8f13be53096fc3eea0191fef0b4b28e3312124ce Mon Sep 17 00:00:00 2001 From: Luis Schaab Date: Wed, 14 Aug 2024 11:01:37 -0300 Subject: [PATCH 12/17] refactor: move all the network-related logic to the hardhat package --- v-next/core/src/internal/utils/package.ts | 19 ------ v-next/core/test/utils.ts | 30 +--------- .../src/internal/network/http-provider.ts} | 18 +++--- .../src/internal/network/provider-errors.ts} | 0 .../src/internal/network}/utils/json-rpc.ts | 0 .../{core => hardhat}/src/types/providers.ts | 0 .../test/internal/network/http-provider.ts} | 12 ++-- v-next/hardhat/test/utils.ts | 58 +++++++++++++++++++ 8 files changed, 74 insertions(+), 63 deletions(-) delete mode 100644 v-next/core/src/internal/utils/package.ts rename v-next/{core/src/internal/providers/http.ts => hardhat/src/internal/network/http-provider.ts} (98%) rename v-next/{core/src/internal/providers/errors.ts => hardhat/src/internal/network/provider-errors.ts} (100%) rename v-next/{core/src/internal => hardhat/src/internal/network}/utils/json-rpc.ts (100%) rename v-next/{core => hardhat}/src/types/providers.ts (100%) rename v-next/{core/test/internal/providers/http.ts => hardhat/test/internal/network/http-provider.ts} (98%) create mode 100644 v-next/hardhat/test/utils.ts diff --git a/v-next/core/src/internal/utils/package.ts b/v-next/core/src/internal/utils/package.ts deleted file mode 100644 index 684cfb381c..0000000000 --- a/v-next/core/src/internal/utils/package.ts +++ /dev/null @@ -1,19 +0,0 @@ -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/core/test/utils.ts b/v-next/core/test/utils.ts index 0b91ec941e..ef80e5744c 100644 --- a/v-next/core/test/utils.ts +++ b/v-next/core/test/utils.ts @@ -1,8 +1,4 @@ -import type { Interceptable } from "@ignored/hardhat-vnext-utils/request"; - -import { after, afterEach, before } from "node:test"; - -import { getTestDispatcher } from "@ignored/hardhat-vnext-utils/request"; +import { afterEach } from "node:test"; export function createTestEnvManager() { const changes = new Set(); @@ -32,27 +28,3 @@ export function createTestEnvManager() { }, }; } - -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; -}; diff --git a/v-next/core/src/internal/providers/http.ts b/v-next/hardhat/src/internal/network/http-provider.ts similarity index 98% rename from v-next/core/src/internal/providers/http.ts rename to v-next/hardhat/src/internal/network/http-provider.ts index c3dc333d13..472b8d6c74 100644 --- a/v-next/core/src/internal/providers/http.ts +++ b/v-next/hardhat/src/internal/network/http-provider.ts @@ -1,12 +1,12 @@ -import type { - EIP1193Provider, - RequestArguments, -} from "../../types/providers.js"; import type { JsonRpcResponse, JsonRpcRequest, SuccessfulJsonRpcResponse, -} from "../utils/json-rpc.js"; +} from "./utils/json-rpc.js"; +import type { + EIP1193Provider, + RequestArguments, +} from "../../types/providers.js"; import type { Dispatcher, RequestOptions, @@ -28,14 +28,14 @@ import { ResponseStatusCodeError, } from "@ignored/hardhat-vnext-utils/request"; +import { getHardhatVersion } from "../utils/package.js"; + +import { ProviderError, ProviderErrorCode } from "./provider-errors.js"; import { getJsonRpcRequest, isFailedJsonRpcResponse, parseJsonRpcResponse, -} from "../utils/json-rpc.js"; -import { getHardhatVersion } from "../utils/package.js"; - -import { ProviderError, ProviderErrorCode } from "./errors.js"; +} from "./utils/json-rpc.js"; const TOO_MANY_REQUEST_STATUS = 429; const MAX_RETRIES = 6; diff --git a/v-next/core/src/internal/providers/errors.ts b/v-next/hardhat/src/internal/network/provider-errors.ts similarity index 100% rename from v-next/core/src/internal/providers/errors.ts rename to v-next/hardhat/src/internal/network/provider-errors.ts diff --git a/v-next/core/src/internal/utils/json-rpc.ts b/v-next/hardhat/src/internal/network/utils/json-rpc.ts similarity index 100% rename from v-next/core/src/internal/utils/json-rpc.ts rename to v-next/hardhat/src/internal/network/utils/json-rpc.ts diff --git a/v-next/core/src/types/providers.ts b/v-next/hardhat/src/types/providers.ts similarity index 100% rename from v-next/core/src/types/providers.ts rename to v-next/hardhat/src/types/providers.ts diff --git a/v-next/core/test/internal/providers/http.ts b/v-next/hardhat/test/internal/network/http-provider.ts similarity index 98% rename from v-next/core/test/internal/providers/http.ts rename to v-next/hardhat/test/internal/network/http-provider.ts index 6c16d3f3fb..4cb3fdaa4b 100644 --- a/v-next/core/test/internal/providers/http.ts +++ b/v-next/hardhat/test/internal/network/http-provider.ts @@ -4,17 +4,17 @@ import { describe, it } from "node:test"; import { HardhatError } from "@ignored/hardhat-vnext-errors"; import { assertRejectsWithHardhatError } from "@nomicfoundation/hardhat-test-utils"; -import { - ProviderError, - ProviderErrorCode, -} from "../../../src/internal/providers/errors.js"; import { HttpProvider, getHttpDispatcher, -} from "../../../src/internal/providers/http.js"; +} from "../../../src/internal/network/http-provider.js"; +import { + ProviderError, + ProviderErrorCode, +} from "../../../src/internal/network/provider-errors.js"; import { createTestEnvManager, initializeTestDispatcher } from "../../utils.js"; -describe("http", () => { +describe("http-provider", () => { describe("HttpProvider.create", () => { it("should create an HttpProvider", async () => { const provider = await HttpProvider.create({ 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; +}; From 8deafcb0b2d4677610371da773dcba107ef89719 Mon Sep 17 00:00:00 2001 From: Luis Schaab Date: Wed, 14 Aug 2024 11:29:21 -0300 Subject: [PATCH 13/17] refactor: use subclasses for provider errors --- .../src/internal/network/http-provider.ts | 9 ++++-- .../src/internal/network/provider-errors.ts | 32 +++++++------------ .../test/internal/network/http-provider.ts | 6 ++-- 3 files changed, 20 insertions(+), 27 deletions(-) diff --git a/v-next/hardhat/src/internal/network/http-provider.ts b/v-next/hardhat/src/internal/network/http-provider.ts index 472b8d6c74..618329604a 100644 --- a/v-next/hardhat/src/internal/network/http-provider.ts +++ b/v-next/hardhat/src/internal/network/http-provider.ts @@ -30,7 +30,7 @@ import { import { getHardhatVersion } from "../utils/package.js"; -import { ProviderError, ProviderErrorCode } from "./provider-errors.js"; +import { ProviderError, LimitExceededError } from "./provider-errors.js"; import { getJsonRpcRequest, isFailedJsonRpcResponse, @@ -130,7 +130,10 @@ export class HttpProvider extends EventEmitter implements EIP1193Provider { const jsonRpcResponse = await this.#fetchJsonRpcResponse(jsonRpcRequest); if (isFailedJsonRpcResponse(jsonRpcResponse)) { - const error = new ProviderError(jsonRpcResponse.error.code); + 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 @@ -293,7 +296,7 @@ export class HttpProvider extends EventEmitter implements EIP1193Provider { } // eslint-disable-next-line no-restricted-syntax -- allow throwing ProviderError - throw new ProviderError(ProviderErrorCode.LIMIT_EXCEEDED); + throw new LimitExceededError(e); } throw e; diff --git a/v-next/hardhat/src/internal/network/provider-errors.ts b/v-next/hardhat/src/internal/network/provider-errors.ts index 59d59a72e7..41bf964317 100644 --- a/v-next/hardhat/src/internal/network/provider-errors.ts +++ b/v-next/hardhat/src/internal/network/provider-errors.ts @@ -6,32 +6,14 @@ import { isObject } from "@ignored/hardhat-vnext-utils/lang"; const IS_PROVIDER_ERROR_PROPERTY_NAME = "_isProviderError"; /** - * The error codes that a provider can return. - * See https://eips.ethereum.org/EIPS/eip-1474#error-codes + * Codes taken from: https://github.com/ethereum/EIPs/blob/master/EIPS/eip-1474.md#error-codes */ -export enum ProviderErrorCode { - LIMIT_EXCEEDED = -32005, - INVALID_PARAMS = -32602, -} - -type ProviderErrorMessages = { - [key in ProviderErrorCode]: string; -}; - -/** - * The error messages associated with each error code. - */ -const ProviderErrorMessage: ProviderErrorMessages = { - [ProviderErrorCode.LIMIT_EXCEEDED]: "Request exceeds defined limit", - [ProviderErrorCode.INVALID_PARAMS]: "Invalid method parameters", -}; - export class ProviderError extends CustomError implements ProviderRpcError { public code: number; public data?: unknown; - constructor(code: ProviderErrorCode, parentError?: Error) { - super(ProviderErrorMessage[code], parentError); + constructor(message: string, code: number, parentError?: Error) { + super(message, parentError); this.code = code; Object.defineProperty(this, IS_PROVIDER_ERROR_PROPERTY_NAME, { @@ -55,3 +37,11 @@ export class ProviderError extends CustomError implements ProviderRpcError { 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/test/internal/network/http-provider.ts b/v-next/hardhat/test/internal/network/http-provider.ts index 4cb3fdaa4b..ed16df4704 100644 --- a/v-next/hardhat/test/internal/network/http-provider.ts +++ b/v-next/hardhat/test/internal/network/http-provider.ts @@ -10,7 +10,7 @@ import { } from "../../../src/internal/network/http-provider.js"; import { ProviderError, - ProviderErrorCode, + LimitExceededError, } from "../../../src/internal/network/provider-errors.js"; import { createTestEnvManager, initializeTestDispatcher } from "../../utils.js"; @@ -247,7 +247,7 @@ describe("http-provider", () => { ProviderError.isProviderError(error), "Error is not a ProviderError", ); - assert.equal(error.code, ProviderErrorCode.LIMIT_EXCEEDED); + assert.equal(error.code, LimitExceededError.CODE); return; } assert.fail("Function did not throw any error"); @@ -285,7 +285,7 @@ describe("http-provider", () => { ProviderError.isProviderError(error), "Error is not a ProviderError", ); - assert.equal(error.code, ProviderErrorCode.LIMIT_EXCEEDED); + assert.equal(error.code, LimitExceededError.CODE); return; } assert.fail("Function did not throw any error"); From 7e133bda7d4aa5894222258027ffc72ead77ab29 Mon Sep 17 00:00:00 2001 From: Luis Schaab Date: Wed, 14 Aug 2024 11:39:35 -0300 Subject: [PATCH 14/17] refactor: extend EthereumProvider interface for HttpProvider --- v-next/hardhat/src/internal/network/http-provider.ts | 6 +++--- v-next/hardhat/src/types/providers.ts | 12 ++++++++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/v-next/hardhat/src/internal/network/http-provider.ts b/v-next/hardhat/src/internal/network/http-provider.ts index 618329604a..a2eba93991 100644 --- a/v-next/hardhat/src/internal/network/http-provider.ts +++ b/v-next/hardhat/src/internal/network/http-provider.ts @@ -4,7 +4,7 @@ import type { SuccessfulJsonRpcResponse, } from "./utils/json-rpc.js"; import type { - EIP1193Provider, + EthereumProvider, RequestArguments, } from "../../types/providers.js"; import type { @@ -41,7 +41,7 @@ const TOO_MANY_REQUEST_STATUS = 429; const MAX_RETRIES = 6; const MAX_RETRY_WAIT_TIME_SECONDS = 5; -export class HttpProvider extends EventEmitter implements EIP1193Provider { +export class HttpProvider extends EventEmitter implements EthereumProvider { readonly #url: string; readonly #networkName: string; readonly #extraHeaders: Record; @@ -185,7 +185,7 @@ export class HttpProvider extends EventEmitter implements EIP1193Provider { */ public sendAsync( jsonRpcRequest: JsonRpcRequest, - callback: (error: any, response: JsonRpcResponse) => void, + callback: (error: any, jsonRpcResponse: JsonRpcResponse) => void, ): void { const handleJsonRpcRequest = async () => { let jsonRpcResponse: JsonRpcResponse; diff --git a/v-next/hardhat/src/types/providers.ts b/v-next/hardhat/src/types/providers.ts index 69060e0158..823628959e 100644 --- a/v-next/hardhat/src/types/providers.ts +++ b/v-next/hardhat/src/types/providers.ts @@ -1,3 +1,7 @@ +import type { + JsonRpcRequest, + JsonRpcResponse, +} from "../internal/network/utils/json-rpc.js"; import type EventEmitter from "node:events"; export interface RequestArguments { @@ -13,3 +17,11 @@ export interface ProviderRpcError extends Error { export interface EIP1193Provider extends EventEmitter { request(args: RequestArguments): Promise; } + +export interface EthereumProvider extends EIP1193Provider { + send(method: string, params?: unknown[]): Promise; + sendAsync( + jsonRpcRequest: JsonRpcRequest, + callback: (error: any, jsonRpcResponse: JsonRpcResponse) => void, + ): void; +} From 7068a5fc633606e278496d74379d8276341d11e7 Mon Sep 17 00:00:00 2001 From: Luis Schaab Date: Wed, 14 Aug 2024 11:49:53 -0300 Subject: [PATCH 15/17] refactor: move jsdoc to the EIP1193Provider and EthereumProvider interfaces --- .../src/internal/network/http-provider.ts | 47 ---------------- v-next/hardhat/src/types/providers.ts | 55 ++++++++++++++++++- 2 files changed, 54 insertions(+), 48 deletions(-) diff --git a/v-next/hardhat/src/internal/network/http-provider.ts b/v-next/hardhat/src/internal/network/http-provider.ts index a2eba93991..05dd5bcddd 100644 --- a/v-next/hardhat/src/internal/network/http-provider.ts +++ b/v-next/hardhat/src/internal/network/http-provider.ts @@ -101,22 +101,6 @@ export class HttpProvider extends EventEmitter implements EthereumProvider { this.#dispatcher = dispatcher; } - /** - * 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. - * @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. - */ public async request( requestArguments: RequestArguments, ): Promise { @@ -145,26 +129,6 @@ export class HttpProvider extends EventEmitter implements EthereumProvider { return jsonRpcResponse.result; } - /** - * @deprecated - * Sends a JSON-RPC request. This method is present for backwards compatibility - * with the Legacy Provider API. Prefer using `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. - * @throws {ProviderError} If the JSON-RPC response indicates a failure. - * @throws {HardhatError} with descriptor: - * - {@link HardhatError.ERRORS.NETWORK.INVALID_REQUEST_METHOD} if the - * method is not a string. - * - {@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. - */ public send( method: string, params?: unknown[], @@ -172,17 +136,6 @@ export class HttpProvider extends EventEmitter implements EthereumProvider { return this.request({ method, params }); } - /** - * @deprecated - * Sends a JSON-RPC request asynchronously. This method is present for - * backwards compatibility with the Legacy Provider API. Prefer using - * `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. - */ public sendAsync( jsonRpcRequest: JsonRpcRequest, callback: (error: any, jsonRpcResponse: JsonRpcResponse) => void, diff --git a/v-next/hardhat/src/types/providers.ts b/v-next/hardhat/src/types/providers.ts index 823628959e..e21679e9bf 100644 --- a/v-next/hardhat/src/types/providers.ts +++ b/v-next/hardhat/src/types/providers.ts @@ -15,11 +15,64 @@ export interface ProviderRpcError extends Error { } export interface EIP1193Provider extends EventEmitter { - request(args: RequestArguments): Promise; + /** + * 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, From a7868e8e9ea4b821b22dfe8f2a503b346fe8d809 Mon Sep 17 00:00:00 2001 From: Luis Schaab Date: Wed, 14 Aug 2024 11:50:12 -0300 Subject: [PATCH 16/17] refactor: remove unused error descriptor --- v-next/hardhat-errors/src/descriptors.ts | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/v-next/hardhat-errors/src/descriptors.ts b/v-next/hardhat-errors/src/descriptors.ts index 5c74bb2330..23068c5426 100644 --- a/v-next/hardhat-errors/src/descriptors.ts +++ b/v-next/hardhat-errors/src/descriptors.ts @@ -483,16 +483,8 @@ Please check Hardhat's output for more details.`, Please check that you are sending a valid URL string for the network or forking \`URL\` parameter.`, }, - INVALID_REQUEST_METHOD: { - number: 701, - messageTemplate: - "Invalid request arguments: the 'method' argument must be a string.", - websiteTitle: "Invalid method parameters", - websiteDescription: - "The JSON-RPC request method argument is invalid. The 'method' argument must be a string representing the name of the method to be invoked. Ensure that the 'method' parameter is correctly specified as a string in your JSON-RPC request.", - }, INVALID_REQUEST_PARAMS: { - number: 702, + number: 701, messageTemplate: "Invalid request arguments: only array parameters are supported.", websiteTitle: "Invalid method parameters", @@ -500,7 +492,7 @@ Please check that you are sending a valid URL string for the network or forking "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: 703, + 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. @@ -508,7 +500,7 @@ Please check that you are sending a valid URL string for the network or forking Please make sure your node is running, and check your internet connection and networks config.`, }, CONNECTION_REFUSED: { - number: 704, + 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", @@ -517,7 +509,7 @@ Please make sure your node is running, and check your internet connection and ne Please make sure your node is running, and check your internet connection and networks config.`, }, NETWORK_TIMEOUT: { - number: 705, + number: 704, messageTemplate: `Network connection timed out. Please check your internet connection and networks config`, websiteTitle: "Network timeout", From 7229bd0a281274f58361b6468a5957a5d29ca124 Mon Sep 17 00:00:00 2001 From: Luis Schaab Date: Wed, 14 Aug 2024 12:44:09 -0300 Subject: [PATCH 17/17] fix: remove unsafe casting with ensureError --- v-next/hardhat-utils/src/errors/request.ts | 42 +++++++++++++++++-- v-next/hardhat-utils/src/internal/request.ts | 15 +++---- v-next/hardhat-utils/src/request.ts | 8 ++-- v-next/hardhat-utils/test/lang.ts | 4 ++ .../src/internal/network/http-provider.ts | 4 +- 5 files changed, 53 insertions(+), 20 deletions(-) diff --git a/v-next/hardhat-utils/src/errors/request.ts b/v-next/hardhat-utils/src/errors/request.ts index 8395a7ea9b..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) { @@ -41,10 +42,43 @@ export class ResponseStatusCodeError extends CustomError { | null; public readonly body: null | Record | string; - constructor(url: string, cause: UndiciT.errors.ResponseStatusCodeError) { + constructor(url: string, cause: Error) { super(`Received an unexpected status code from ${sanitizeUrl(url)}`, cause); - this.statusCode = cause.statusCode; - this.headers = cause.headers; - this.body = cause.body; + 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 baf15cd284..32ddd043b3 100644 --- a/v-next/hardhat-utils/src/internal/request.ts +++ b/v-next/hardhat-utils/src/internal/request.ts @@ -5,8 +5,8 @@ import type UndiciT from "undici"; import path from "node:path"; import url from "node:url"; -import { ensureError } from "../error.js"; import { mkdir } from "../fs.js"; +import { isObject } from "../lang.js"; import { ConnectionRefusedError, DEFAULT_MAX_REDIRECTS, @@ -140,16 +140,12 @@ export function sanitizeUrl(requestUrl: string): string { return url.format(parsedUrl, { auth: false, search: false, fragment: false }); } -export function handleError( - e: NodeJS.ErrnoException, - requestUrl: string, -): void { - let causeCode; - if (e.cause !== undefined) { - ensureError(e.cause); +export function handleError(e: Error, requestUrl: string): void { + let causeCode: unknown; + if (isObject(e.cause)) { causeCode = e.cause.code; } - const errorCode = e.code ?? causeCode; + const errorCode = "code" in e ? e.code : causeCode; if (errorCode === "ECONNREFUSED") { throw new ConnectionRefusedError(requestUrl, e); @@ -164,7 +160,6 @@ export function handleError( } if (errorCode === "UND_ERR_RESPONSE_STATUS_CODE") { - ensureError(e); throw new ResponseStatusCodeError(requestUrl, e); } } diff --git a/v-next/hardhat-utils/src/request.ts b/v-next/hardhat-utils/src/request.ts index 470944c946..44c23ff221 100644 --- a/v-next/hardhat-utils/src/request.ts +++ b/v-next/hardhat-utils/src/request.ts @@ -91,7 +91,7 @@ export async function getRequest( ...baseRequestOptions, }); } catch (e) { - ensureError(e); + ensureError(e); handleError(e, url); @@ -135,7 +135,7 @@ export async function postJsonRequest( body: JSON.stringify(body), }); } catch (e) { - ensureError(e); + ensureError(e); handleError(e, url); @@ -180,7 +180,7 @@ export async function postFormRequest( body: querystring.stringify(body as ParsedUrlQueryInput), }); } catch (e) { - ensureError(e); + ensureError(e); handleError(e, url); @@ -225,7 +225,7 @@ export async function download( await stream.pipeline(body, fileStream); await move(tempFilePath, destination); } catch (e) { - ensureError(e); + ensureError(e); handleError(e, url); diff --git a/v-next/hardhat-utils/test/lang.ts b/v-next/hardhat-utils/test/lang.ts index 7200c23b07..3f64b4a57e 100644 --- a/v-next/hardhat-utils/test/lang.ts +++ b/v-next/hardhat-utils/test/lang.ts @@ -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", () => { diff --git a/v-next/hardhat/src/internal/network/http-provider.ts b/v-next/hardhat/src/internal/network/http-provider.ts index 05dd5bcddd..11183fa6e1 100644 --- a/v-next/hardhat/src/internal/network/http-provider.ts +++ b/v-next/hardhat/src/internal/network/http-provider.ts @@ -153,9 +153,9 @@ export class HttpProvider extends EventEmitter implements EthereumProvider { result, }; } catch (error) { - ensureError(error); + ensureError(error); - if (error.code === undefined) { + if (!("code" in error) || error.code === undefined) { throw error; }