From 7f9a2c43d208414ebaa8e019d1f3d26f590e1200 Mon Sep 17 00:00:00 2001 From: galargh Date: Fri, 6 Dec 2024 16:14:07 +0100 Subject: [PATCH 1/2] feat: port the node task to hardhat v3 --- pnpm-lock.yaml | 54 +++- v-next/hardhat-errors/src/descriptors.ts | 10 + v-next/hardhat/package.json | 3 + .../src/internal/builtin-plugins/index.ts | 3 + .../network-manager/json-rpc.ts | 31 ++ .../internal/builtin-plugins/node/index.ts | 59 ++++ .../builtin-plugins/node/json-rpc/handler.ts | 294 ++++++++++++++++++ .../builtin-plugins/node/json-rpc/server.ts | 101 ++++++ .../builtin-plugins/node/task-action.ts | 138 ++++++++ .../builtin-plugins/node/json-rpc/server.ts | 52 ++++ v-next/hardhat/test/internal/cli/main.ts | 1 + 11 files changed, 733 insertions(+), 13 deletions(-) create mode 100644 v-next/hardhat/src/internal/builtin-plugins/node/index.ts create mode 100644 v-next/hardhat/src/internal/builtin-plugins/node/json-rpc/handler.ts create mode 100644 v-next/hardhat/src/internal/builtin-plugins/node/json-rpc/server.ts create mode 100644 v-next/hardhat/src/internal/builtin-plugins/node/task-action.ts create mode 100644 v-next/hardhat/test/internal/builtin-plugins/node/json-rpc/server.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 521e1c18d8..d616ee400c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1518,6 +1518,9 @@ importers: p-map: specifier: ^7.0.2 version: 7.0.2 + raw-body: + specifier: ^2.4.1 + version: 2.5.2 semver: specifier: ^7.6.3 version: 7.6.3 @@ -1527,6 +1530,9 @@ importers: tsx: specifier: ^4.11.0 version: 4.19.2 + ws: + specifier: ^8.18.0 + version: 8.18.0 zod: specifier: ^3.23.8 version: 3.23.8 @@ -1552,6 +1558,9 @@ importers: '@types/semver': specifier: ^7.5.8 version: 7.5.8 + '@types/ws': + specifier: ^8.5.13 + version: 8.5.13 '@typescript-eslint/eslint-plugin': specifier: ^7.7.1 version: 7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.5.4))(eslint@8.57.0)(typescript@5.5.4) @@ -3444,6 +3453,9 @@ packages: '@types/node@20.17.1': resolution: {integrity: sha512-j2VlPv1NnwPJbaCNv69FO/1z4lId0QmGvpT41YxitRtWlg96g/j8qcv2RKsLKe2F6OJgyXhupN1Xo17b2m139Q==} + '@types/node@22.10.0': + resolution: {integrity: sha512-XC70cRZVElFHfIUB40FgZOBbgJYFKKMa5nb9lxcwYstFG/Mi+/Y0bGS+rs6Dmhmkpq4pnNiLiuZAbc02YCOnmA==} + '@types/node@22.7.5': resolution: {integrity: sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==} @@ -3495,6 +3507,9 @@ packages: '@types/ws@7.4.7': resolution: {integrity: sha512-JQbbmxZTZehdc2iszGKs5oC3NFnjeay7mtAWrdt7qNtAVK0g19muApzAy4bm9byz79xa2ZnO/BOBC2R8RC5Lww==} + '@types/ws@8.5.13': + resolution: {integrity: sha512-osM/gWBTPKgHV8XkTunnegTRIsvF6owmf5w+JtAfOw472dptdm0dlGv4xCt6GwQRcC2XVOvvRE/0bAoQcL2QkA==} + '@types/ws@8.5.3': resolution: {integrity: sha512-6YOoWjruKj1uLf3INHH7D3qTXwFfEsg1kf3c0uDdSBJwfa/llkwIjrAGV7j7mVgGNbzTQ3HiHKKDXl6bJPD97w==} @@ -6592,6 +6607,9 @@ packages: undici-types@6.19.8: resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==} + undici-types@6.20.0: + resolution: {integrity: sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==} + undici@5.28.4: resolution: {integrity: sha512-72RFADWFqKmUb2hmmvNODKL3p9hcB6Gt2DOQMis1SEBaV6a4MH8soBvzg+95CYhCKPFedut2JY9bMfrDl9D23g==} engines: {node: '>=14.0'} @@ -8123,7 +8141,7 @@ snapshots: '@types/adm-zip@0.5.5': dependencies: - '@types/node': 20.17.1 + '@types/node': 22.10.0 '@types/async-eventemitter@0.2.4': dependencies: @@ -8131,11 +8149,11 @@ snapshots: '@types/bn.js@4.11.6': dependencies: - '@types/node': 20.17.1 + '@types/node': 22.10.0 '@types/bn.js@5.1.6': dependencies: - '@types/node': 18.19.59 + '@types/node': 22.10.0 '@types/chai-as-promised@7.1.8': dependencies: @@ -8151,7 +8169,7 @@ snapshots: '@types/concat-stream@1.6.1': dependencies: - '@types/node': 20.17.1 + '@types/node': 22.10.0 '@types/debug@4.1.12': dependencies: @@ -8163,16 +8181,16 @@ snapshots: '@types/form-data@0.0.33': dependencies: - '@types/node': 20.17.1 + '@types/node': 22.10.0 '@types/fs-extra@5.1.0': dependencies: - '@types/node': 18.19.59 + '@types/node': 22.10.0 '@types/glob@7.2.0': dependencies: '@types/minimatch': 5.1.2 - '@types/node': 18.19.59 + '@types/node': 22.10.0 '@types/istanbul-lib-coverage@2.0.6': {} @@ -8182,7 +8200,7 @@ snapshots: '@types/keccak@3.0.5': dependencies: - '@types/node': 18.19.59 + '@types/node': 22.10.0 '@types/lodash.clonedeep@4.5.9': dependencies: @@ -8218,6 +8236,10 @@ snapshots: dependencies: undici-types: 6.19.8 + '@types/node@22.10.0': + dependencies: + undici-types: 6.20.0 + '@types/node@22.7.5': dependencies: undici-types: 6.19.8 @@ -8230,7 +8252,7 @@ snapshots: '@types/pbkdf2@3.1.2': dependencies: - '@types/node': 20.17.1 + '@types/node': 22.10.0 '@types/prettier@2.7.3': {} @@ -8238,14 +8260,14 @@ snapshots: '@types/readable-stream@2.3.15': dependencies: - '@types/node': 20.17.1 + '@types/node': 22.10.0 safe-buffer: 5.1.2 '@types/resolve@1.20.6': {} '@types/secp256k1@4.0.6': dependencies: - '@types/node': 20.17.1 + '@types/node': 22.10.0 '@types/semver@6.2.7': {} @@ -8268,11 +8290,15 @@ snapshots: '@types/ws@7.4.7': dependencies: - '@types/node': 18.19.59 + '@types/node': 22.10.0 + + '@types/ws@8.5.13': + dependencies: + '@types/node': 22.10.0 '@types/ws@8.5.3': dependencies: - '@types/node': 20.17.1 + '@types/node': 22.10.0 '@typescript-eslint/eslint-plugin@5.61.0(@typescript-eslint/parser@5.61.0(eslint@8.57.0)(typescript@5.0.4))(eslint@8.57.0)(typescript@5.0.4)': dependencies: @@ -11843,6 +11869,8 @@ snapshots: undici-types@6.19.8: {} + undici-types@6.20.0: {} + undici@5.28.4: dependencies: '@fastify/busboy': 2.1.1 diff --git a/v-next/hardhat-errors/src/descriptors.ts b/v-next/hardhat-errors/src/descriptors.ts index 0ae5eaf16c..e04d50dda7 100644 --- a/v-next/hardhat-errors/src/descriptors.ts +++ b/v-next/hardhat-errors/src/descriptors.ts @@ -85,6 +85,7 @@ export const ERROR_CATEGORIES: { }, SOLIDITY: { min: 1200, max: 1299, websiteTitle: "Solidity errors" }, VIEM: { min: 1300, max: 1399, websiteTitle: "Hardhat-viem errors" }, + NODE: { min: 1400, max: 1499, websiteTitle: "Hardhat node errors" }, }; export const ERRORS = { @@ -1247,4 +1248,13 @@ Please check Hardhat's output for more details.`, "The deployment transaction was mined but its receipt doesn't contain a contract address.", }, }, + NODE: { + INVALID_NETWORK_TYPE: { + number: 1400, + messageTemplate: + "The provided node network type {networkType} for network {networkName} is not recognized, only `edr` is supported.", + websiteTitle: "Invalid node network type", + websiteDescription: `The node only supports the 'edr' network type.`, + }, + }, } as const; diff --git a/v-next/hardhat/package.json b/v-next/hardhat/package.json index a6c8d29923..79747009a0 100644 --- a/v-next/hardhat/package.json +++ b/v-next/hardhat/package.json @@ -68,6 +68,7 @@ "@types/debug": "^4.1.4", "@types/node": "^20.14.9", "@types/semver": "^7.5.8", + "@types/ws": "^8.5.13", "@typescript-eslint/eslint-plugin": "^7.7.1", "@typescript-eslint/parser": "^7.7.1", "eslint": "8.57.0", @@ -96,9 +97,11 @@ "ethereum-cryptography": "^2.2.1", "micro-eth-signer": "^0.13.0", "p-map": "^7.0.2", + "raw-body": "^2.4.1", "semver": "^7.6.3", "solc": "^0.8.27", "tsx": "^4.11.0", + "ws": "^8.18.0", "zod": "^3.23.8" } } diff --git a/v-next/hardhat/src/internal/builtin-plugins/index.ts b/v-next/hardhat/src/internal/builtin-plugins/index.ts index b20e78f2df..ad34afb141 100644 --- a/v-next/hardhat/src/internal/builtin-plugins/index.ts +++ b/v-next/hardhat/src/internal/builtin-plugins/index.ts @@ -4,6 +4,7 @@ import artifacts from "./artifacts/index.js"; import clean from "./clean/index.js"; import console from "./console/index.js"; import networkManager from "./network-manager/index.js"; +import node from "./node/index.js"; import run from "./run/index.js"; import solidity from "./solidity/index.js"; import solidityTest from "./solidity-test/index.js"; @@ -19,6 +20,7 @@ export type * from "./network-manager/index.js"; export type * from "./clean/index.js"; export type * from "./console/index.js"; export type * from "./run/index.js"; +export type * from "./node/index.js"; // This array should be kept in order, respecting the dependencies between the // plugins. @@ -31,4 +33,5 @@ export const builtinPlugins: HardhatPlugin[] = [ clean, console, run, + node, ]; diff --git a/v-next/hardhat/src/internal/builtin-plugins/network-manager/json-rpc.ts b/v-next/hardhat/src/internal/builtin-plugins/network-manager/json-rpc.ts index 4f287aa204..cc6064d46b 100644 --- a/v-next/hardhat/src/internal/builtin-plugins/network-manager/json-rpc.ts +++ b/v-next/hardhat/src/internal/builtin-plugins/network-manager/json-rpc.ts @@ -3,6 +3,7 @@ import type { JsonRpcRequest, JsonRpcResponse, RequestArguments, + SuccessfulJsonRpcResponse, } from "../../../types/providers.js"; import { HardhatError } from "@ignored/hardhat-vnext-errors"; @@ -56,6 +57,30 @@ export function parseJsonRpcResponse( } } +export function isJsonRpcRequest(payload: unknown): payload is JsonRpcRequest { + if (!isObject(payload)) { + return false; + } + + if (payload.jsonrpc !== "2.0") { + return false; + } + + if (typeof payload.id !== "number" && typeof payload.id !== "string") { + return false; + } + + if (typeof payload.method !== "string") { + return false; + } + + if (payload.params !== undefined && !Array.isArray(payload.params)) { + return false; + } + + return true; +} + export function isJsonRpcResponse( payload: unknown, ): payload is JsonRpcResponse { @@ -96,6 +121,12 @@ export function isJsonRpcResponse( return true; } +export function isSuccessfulJsonRpcResponse( + payload: JsonRpcResponse, +): payload is SuccessfulJsonRpcResponse { + return "result" in payload; +} + export function isFailedJsonRpcResponse( payload: JsonRpcResponse, ): payload is FailedJsonRpcResponse { diff --git a/v-next/hardhat/src/internal/builtin-plugins/node/index.ts b/v-next/hardhat/src/internal/builtin-plugins/node/index.ts new file mode 100644 index 0000000000..2538dc7663 --- /dev/null +++ b/v-next/hardhat/src/internal/builtin-plugins/node/index.ts @@ -0,0 +1,59 @@ +import type { HardhatPlugin } from "../../../types/plugins.js"; + +import { ArgumentType } from "../../../types/arguments.js"; +import { task } from "../../core/config.js"; + +const hardhatPlugin: HardhatPlugin = { + id: "builtin:node", + tasks: [ + task("node", "Starts a JSON-RPC server on top of Hardhat Network") + .addOption({ + name: "hostname", + description: + "The host to which to bind to for new connections (Defaults to 127.0.0.1 running locally, and 0.0.0.0 in Docker)", + defaultValue: "", + }) + .addOption({ + name: "port", + description: "The port on which to listen for new connections", + type: ArgumentType.INT, + defaultValue: 8545, + }) + .addOption({ + name: "chainType", + description: + "The chain type to connect to. If not specified, the default chain type will be used.", + defaultValue: "", + }) + .addOption({ + name: "chainId", + description: + "The chain id to connect to. If not specified, the default chain id will be used.", + type: ArgumentType.INT, + defaultValue: -1, + }) + .addOption({ + name: "fork", + description: "The URL of the JSON-RPC server to fork from", + defaultValue: "", + }) + .addOption({ + name: "forkBlockNumber", + description: "The block number to fork from", + type: ArgumentType.INT, + defaultValue: -1, + }) + .setAction(import.meta.resolve("./task-action.js")) + .build(), + ], + dependencies: [ + async () => { + const { default: networkManagerBuiltinPlugin } = await import( + "../network-manager/index.js" + ); + return networkManagerBuiltinPlugin; + }, + ], +}; + +export default hardhatPlugin; diff --git a/v-next/hardhat/src/internal/builtin-plugins/node/json-rpc/handler.ts b/v-next/hardhat/src/internal/builtin-plugins/node/json-rpc/handler.ts new file mode 100644 index 0000000000..c29b7bd1e8 --- /dev/null +++ b/v-next/hardhat/src/internal/builtin-plugins/node/json-rpc/handler.ts @@ -0,0 +1,294 @@ +import type { + EIP1193Provider, + FailedJsonRpcResponse, + JsonRpcRequest, + JsonRpcResponse, +} from "../../../../types/providers.js"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import type WebSocket from "ws"; + +import getRawBody from "raw-body"; + +import { + InternalError, + InvalidJsonInputError, + InvalidRequestError, +} from "../../network-manager/edr/errors.js"; +import { + isJsonRpcRequest, + isJsonRpcResponse, + isSuccessfulJsonRpcResponse, +} from "../../network-manager/json-rpc.js"; +import { ProviderError } from "../../network-manager/provider-errors.js"; + +export class JsonRpcHandler { + readonly #provider: EIP1193Provider; + + constructor(provider: EIP1193Provider) { + this.#provider = provider; + } + + public handleHttp = async ( + req: IncomingMessage, + res: ServerResponse, + ): Promise => { + this.#setCorsHeaders(res); + if (req.method === "OPTIONS") { + this.#sendEmptyResponse(res); + return; + } + + let jsonHttpRequest: any; + try { + jsonHttpRequest = await _readJsonHttpRequest(req); + } catch (error) { + this.#sendResponse(res, _handleError(error)); + return; + } + + if (Array.isArray(jsonHttpRequest)) { + const responses = await Promise.all( + jsonHttpRequest.map((singleReq: any) => + this.#handleSingleRequest(singleReq), + ), + ); + + this.#sendResponse(res, responses); + return; + } + + const rpcResp = await this.#handleSingleRequest(jsonHttpRequest); + + this.#sendResponse(res, rpcResp); + }; + + public handleWs = async (ws: WebSocket): Promise => { + const subscriptions: string[] = []; + let isClosed = false; + + const listener = (payload: { subscription: string; result: any }) => { + // Don't attempt to send a message to the websocket if we already know it is closed, + // or the current websocket connection isn't interested in the particular subscription. + if (isClosed || !subscriptions.includes(payload.subscription)) { + return; + } + + try { + ws.send( + JSON.stringify({ + jsonrpc: "2.0", + method: "eth_subscription", + params: payload, + }), + ); + } catch (error) { + _handleError(error); + } + }; + + // Handle eth_subscribe notifications. + this.#provider.addListener("notification", listener); + + ws.on("message", async (msg: string) => { + let rpcReq: JsonRpcRequest | JsonRpcRequest[]; + let rpcResp: JsonRpcResponse | JsonRpcResponse[]; + + try { + rpcReq = _readWsRequest(msg); + + rpcResp = Array.isArray(rpcReq) + ? await Promise.all( + rpcReq.map((req) => + this.#handleSingleWsRequest(req, subscriptions), + ), + ) + : await this.#handleSingleWsRequest(rpcReq, subscriptions); + } catch (error) { + rpcResp = _handleError(error); + } + + ws.send(JSON.stringify(rpcResp)); + }); + + ws.on("close", () => { + // Remove eth_subscribe listener. + this.#provider.removeListener("notification", listener); + + // Clear any active subscriptions for the closed websocket connection. + isClosed = true; + subscriptions.forEach(async (subscriptionId) => { + await this.#provider.request({ + method: "eth_unsubscribe", + params: [subscriptionId], + }); + }); + }); + }; + + #sendEmptyResponse(res: ServerResponse) { + res.writeHead(200); + res.end(); + } + + #setCorsHeaders(res: ServerResponse) { + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Request-Method", "*"); + res.setHeader("Access-Control-Allow-Methods", "OPTIONS, GET"); + res.setHeader("Access-Control-Allow-Headers", "*"); + } + + #sendResponse( + res: ServerResponse, + rpcResp: JsonRpcResponse | JsonRpcResponse[], + ) { + res.statusCode = 200; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify(rpcResp)); + } + + async #handleSingleRequest(req: JsonRpcRequest): Promise { + if (!isJsonRpcRequest(req)) { + return _handleError(new InvalidRequestError("Invalid request")); + } + + const rpcReq: JsonRpcRequest = req; + let rpcResp: JsonRpcResponse | undefined; + + try { + rpcResp = await this.#handleRequest(rpcReq); + } catch (error) { + rpcResp = _handleError(error); + } + + // Validate the RPC response. + if (!isJsonRpcResponse(rpcResp)) { + // Malformed response coming from the provider, report to user as an internal error. + rpcResp = _handleError(new InternalError("Internal error")); + } + + if (rpcReq !== undefined) { + rpcResp.id = rpcReq.id !== undefined ? rpcReq.id : null; + } + + return rpcResp; + } + + async #handleSingleWsRequest( + rpcReq: JsonRpcRequest, + subscriptions: string[], + ) { + const rpcResp = await this.#handleSingleRequest(rpcReq); + + // If eth_subscribe was successful, keep track of the subscription id, + // so we can cleanup on websocket close. + if ( + rpcReq.method === "eth_subscribe" && + isSuccessfulJsonRpcResponse(rpcResp) && + typeof rpcResp.result === "string" + ) { + subscriptions.push(rpcResp.result); + } + + return rpcResp; + } + + readonly #handleRequest = async ( + req: JsonRpcRequest, + ): Promise => { + const result = await this.#provider.request({ + method: req.method, + params: req.params, + }); + + return { + jsonrpc: "2.0", + id: req.id, + result, + }; + }; +} + +const _readJsonHttpRequest = async (req: IncomingMessage): Promise => { + let json; + + try { + const buf = await getRawBody(req); + const text = buf.toString(); + + json = JSON.parse(text); + } catch (error) { + if (error instanceof Error) { + // eslint-disable-next-line no-restricted-syntax -- Malformed JSON-RPC request received, report to user as a json input error. + throw new InvalidJsonInputError(`Parse error: ${error.message}`); + } + + throw error; + } + + return json; +}; + +const _readWsRequest = (msg: string): JsonRpcRequest | JsonRpcRequest[] => { + let json: any; + try { + json = JSON.parse(msg); + } catch (error) { + if (error instanceof Error) { + // eslint-disable-next-line no-restricted-syntax -- Malformed JSON-RPC request received, report to user as a json input error. + throw new InvalidJsonInputError(`Parse error: ${error.message}`); + } + throw error; + } + + return json; +}; + +const _handleError = (error: any): JsonRpcResponse => { + // extract the relevant fields from the error before wrapping it + let txHash: string | undefined; + let returnData: string | undefined; + + if (error.transactionHash !== undefined) { + txHash = error.transactionHash; + } + if (error.data !== undefined) { + if (error.data?.data !== undefined) { + returnData = error.data.data; + } else { + returnData = error.data; + } + + if (txHash === undefined && error.data?.transactionHash !== undefined) { + txHash = error.data.transactionHash; + } + } + + // In case of non-hardhat error, treat it as internal and associate the appropriate error code. + if (!ProviderError.isProviderError(error)) { + error = new InternalError(error); + } + + const response: FailedJsonRpcResponse = { + jsonrpc: "2.0", + id: null, + error: { + code: error.code, + message: error.message, + }, + }; + + const data: any = { + message: error.message, + }; + + if (txHash !== undefined) { + } + + if (returnData !== undefined) { + data.data = returnData; + } + + response.error.data = data; + + return response; +}; diff --git a/v-next/hardhat/src/internal/builtin-plugins/node/json-rpc/server.ts b/v-next/hardhat/src/internal/builtin-plugins/node/json-rpc/server.ts new file mode 100644 index 0000000000..bce7c5f3c6 --- /dev/null +++ b/v-next/hardhat/src/internal/builtin-plugins/node/json-rpc/server.ts @@ -0,0 +1,101 @@ +import type { EIP1193Provider } from "../../../../types/providers.js"; +import type { Server } from "node:http"; +import type { AddressInfo } from "node:net"; + +import http from "node:http"; + +import debug from "debug"; +import { WebSocketServer } from "ws"; + +import { JsonRpcHandler } from "./handler.js"; + +const log = debug("hardhat:core:tasks:node:json-rpc:server"); + +export interface IJsonRpcServer { + listen(): Promise<{ address: string; port: number }>; + waitUntilClosed(): Promise; + + close(): Promise; +} + +export interface JsonRpcServerConfig { + hostname: string; + port: number; + + provider: EIP1193Provider; +} + +export class JsonRpcServer implements IJsonRpcServer { + readonly #config: JsonRpcServerConfig; + readonly #httpServer: Server; + readonly #wsServer: WebSocketServer; + + constructor(config: JsonRpcServerConfig) { + this.#config = config; + + const handler = new JsonRpcHandler(config.provider); + + this.#httpServer = http.createServer(); + this.#wsServer = new WebSocketServer({ + server: this.#httpServer, + }); + + this.#httpServer.on("request", handler.handleHttp); + this.#wsServer.on("connection", handler.handleWs); + } + + public listen = (): Promise<{ address: string; port: number }> => { + return new Promise((resolve) => { + log(`Starting JSON-RPC server on port ${this.#config.port}`); + this.#httpServer.listen(this.#config.port, this.#config.hostname, () => { + // We get the address and port directly from the server in order to handle random port allocation with `0`. + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- TCP sockets return AddressInfo + const address = this.#httpServer.address() as AddressInfo; + resolve(address); + }); + }); + }; + + public waitUntilClosed = async (): Promise => { + const httpServerClosed = new Promise((resolve) => { + this.#httpServer.once("close", resolve); + }); + + const wsServerClosed = new Promise((resolve) => { + this.#wsServer.once("close", resolve); + }); + + await Promise.all([httpServerClosed, wsServerClosed]); + }; + + public close = async (): Promise => { + await Promise.all([ + new Promise((resolve, reject) => { + log("Closing JSON-RPC server"); + this.#httpServer.close((err) => { + if (err !== null && err !== undefined) { + log("Failed to close JSON-RPC server"); + reject(err); + return; + } + + log("JSON-RPC server closed"); + resolve(); + }); + }), + new Promise((resolve, reject) => { + log("Closing websocket server"); + this.#wsServer.close((err) => { + if (err !== null && err !== undefined) { + log("Failed to close websocket server"); + reject(err); + return; + } + + log("Websocket server closed"); + resolve(); + }); + }), + ]); + }; +} diff --git a/v-next/hardhat/src/internal/builtin-plugins/node/task-action.ts b/v-next/hardhat/src/internal/builtin-plugins/node/task-action.ts new file mode 100644 index 0000000000..2e6f6e5e65 --- /dev/null +++ b/v-next/hardhat/src/internal/builtin-plugins/node/task-action.ts @@ -0,0 +1,138 @@ +import type { EdrNetworkConfig } from "../../../types/config.js"; +import type { NewTaskActionFunction } from "../../../types/tasks.js"; + +import { HardhatError } from "@ignored/hardhat-vnext-errors"; +import { exists } from "@ignored/hardhat-vnext-utils/fs"; +import chalk from "chalk"; + +import { JsonRpcServer } from "./json-rpc/server.js"; + +interface NodeActionArguments { + hostname: string; + port: number; + chainType: string; + chainId: number; + fork: string; + forkBlockNumber: number; +} + +const nodeAction: NewTaskActionFunction = async ( + args, + hre, +) => { + const network = + hre.globalOptions.network !== "" + ? hre.globalOptions.network + : hre.config.defaultNetwork; + + if (!(network in hre.config.networks)) { + throw new HardhatError(HardhatError.ERRORS.NETWORK.NETWORK_NOT_FOUND, { + networkName: network, + }); + } + + if (hre.config.networks[network].type !== "edr") { + throw new HardhatError(HardhatError.ERRORS.NODE.INVALID_NETWORK_TYPE, { + networkType: hre.config.networks[network].type, + networkName: network, + }); + } + + // NOTE: We create an empty network config override here. We add to it based + // on the result of arguments parsing. We can expand the list of arguments + // as much as needed. + const networkConfigOverride: Partial = {}; + + if (args.chainType !== "") { + if ( + args.chainType !== "generic" && + args.chainType !== "l1" && + args.chainType !== "optimism" + ) { + // NOTE: We could make the error more specific here. + throw new HardhatError( + HardhatError.ERRORS.ARGUMENTS.INVALID_VALUE_FOR_TYPE, + { + value: args.chainType, + type: "ChainType", + name: "chainType", + }, + ); + } + networkConfigOverride.chainType = args.chainType; + } + + if (args.chainId !== -1) { + networkConfigOverride.chainId = args.chainId; + } + + // NOTE: --fork-block-number is only valid if --fork is specified + if (args.fork !== "") { + networkConfigOverride.forkConfig = { + jsonRpcUrl: args.fork, + }; + if (args.forkBlockNumber !== -1) { + networkConfigOverride.forkConfig.blockNumber = BigInt( + args.forkBlockNumber, + ); + } + } else if (args.forkBlockNumber !== -1) { + // NOTE: We could make the error more specific here. + throw new HardhatError( + HardhatError.ERRORS.ARGUMENTS.MISSING_VALUE_FOR_ARGUMENT, + { + argument: "fork", + }, + ); + } + + // NOTE: This is where we initialize the network; the connect method returns + // a fully resolved networkConfig object which might be useful for display + const { provider } = await hre.network.connect( + network, + undefined, + networkConfigOverride, + ); + + // NOTE: We enable logging for the node + await provider.request({ + method: "hardhat_setLoggingEnabled", + params: [true], + }); + + // the default hostname is "127.0.0.1" unless we are inside a docker + // container, in that case we use "0.0.0.0" + let hostname = args.hostname; + if (hostname === "") { + const insideDocker = await exists("/.dockerenv"); + if (insideDocker) { + hostname = "0.0.0.0"; + } else { + hostname = "127.0.0.1"; + } + } + + const server: JsonRpcServer = new JsonRpcServer({ + hostname, + port: args.port, + provider, + }); + + const { port: actualPort, address: actualHostname } = await server.listen(); + + console.log( + chalk.green( + `Started HTTP and WebSocket JSON-RPC server at http://${actualHostname}:${actualPort}/`, + ), + ); + + console.log(); + + // TODO(https://github.com/NomicFoundation/hardhat/issues/6040): Add build info watcher here + + // TODO(https://github.com/NomicFoundation/hardhat/issues/6042): Add accounts info printing here + + await server.waitUntilClosed(); +}; + +export default nodeAction; diff --git a/v-next/hardhat/test/internal/builtin-plugins/node/json-rpc/server.ts b/v-next/hardhat/test/internal/builtin-plugins/node/json-rpc/server.ts new file mode 100644 index 0000000000..bd0a72225a --- /dev/null +++ b/v-next/hardhat/test/internal/builtin-plugins/node/json-rpc/server.ts @@ -0,0 +1,52 @@ +import type { HardhatRuntimeEnvironment } from "../../../../../src/types/hre.js"; + +import assert from "node:assert/strict"; +import { before, describe, it } from "node:test"; + +import { exists } from "@ignored/hardhat-vnext-utils/fs"; + +import { HttpProvider } from "../../../../../src/internal/builtin-plugins/network-manager/http-provider.js"; +import { JsonRpcServer } from "../../../../../src/internal/builtin-plugins/node/json-rpc/server.js"; +import { createHardhatRuntimeEnvironment } from "../../../../../src/internal/hre-intialization.js"; + +describe("JSON-RPC server", function () { + let hre: HardhatRuntimeEnvironment; + + before(async function () { + hre = await createHardhatRuntimeEnvironment({}); + }); + + it("should respond to a request over the network the same as in memory", async function () { + const hostname = (await exists("/.dockerenv")) ? "0.0.0.0" : "127.0.0.1"; + const port = 8545; + + const connection = await hre.network.connect(); + const server = new JsonRpcServer({ + hostname, + port, + provider: connection.provider, + }); + + try { + await server.listen(); + + const edrProvider = connection.provider; + const httpProvider = await HttpProvider.create({ + url: `http://${hostname}:${port}`, + networkName: connection.networkName, + timeout: 20_000, + }); + + const httpResponse = await httpProvider.request({ + method: "eth_chainId", + }); + const edrResponse = await edrProvider.request({ + method: "eth_chainId", + }); + + assert.deepEqual(httpResponse, edrResponse); + } finally { + await server.close(); + } + }); +}); diff --git a/v-next/hardhat/test/internal/cli/main.ts b/v-next/hardhat/test/internal/cli/main.ts index 08e1970e98..337c881c4a 100644 --- a/v-next/hardhat/test/internal/cli/main.ts +++ b/v-next/hardhat/test/internal/cli/main.ts @@ -226,6 +226,7 @@ AVAILABLE TASKS: clean Clears the cache and deletes all artifacts compile Compiles your project console Opens a hardhat console + node Starts a JSON-RPC server on top of Hardhat Network run Runs a user-defined script after compiling the project task A task that uses arg1 test Runs all your tests From 5b91fdb84f008b0d8805ffebee1992503796110b Mon Sep 17 00:00:00 2001 From: galargh Date: Tue, 17 Dec 2024 12:08:49 +0100 Subject: [PATCH 2/2] chore: apply the review suggestions --- pnpm-lock.yaml | 37 +++++----- v-next/hardhat/package.json | 1 - .../network-manager/json-rpc.ts | 2 +- .../builtin-plugins/node/json-rpc/handler.ts | 73 ++++++++----------- .../builtin-plugins/node/json-rpc/server.ts | 8 +- .../builtin-plugins/node/task-action.ts | 4 +- .../builtin-plugins/node/json-rpc/server.ts | 4 +- 7 files changed, 59 insertions(+), 70 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d616ee400c..fb694b5720 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1505,7 +1505,7 @@ importers: version: 5.3.0 debug: specifier: ^4.1.1 - version: 4.3.7(supports-color@8.1.1) + version: 4.3.7 enquirer: specifier: ^2.3.0 version: 2.4.1 @@ -1518,9 +1518,6 @@ importers: p-map: specifier: ^7.0.2 version: 7.0.2 - raw-body: - specifier: ^2.4.1 - version: 2.5.2 semver: specifier: ^7.6.3 version: 7.6.3 @@ -7295,7 +7292,7 @@ snapshots: '@eslint/eslintrc@2.1.4': dependencies: ajv: 6.12.6 - debug: 4.3.7(supports-color@8.1.1) + debug: 4.3.7 espree: 9.6.1 globals: 13.24.0 ignore: 5.3.2 @@ -7596,7 +7593,7 @@ snapshots: '@humanwhocodes/config-array@0.11.14': dependencies: '@humanwhocodes/object-schema': 2.0.3 - debug: 4.3.7(supports-color@8.1.1) + debug: 4.3.7 minimatch: 3.1.2 transitivePeerDependencies: - supports-color @@ -8345,7 +8342,7 @@ snapshots: '@typescript-eslint/type-utils': 7.7.1(eslint@8.57.0)(typescript@5.5.4) '@typescript-eslint/utils': 7.7.1(eslint@8.57.0)(typescript@5.5.4) '@typescript-eslint/visitor-keys': 7.7.1 - debug: 4.3.7(supports-color@8.1.1) + debug: 4.3.7 eslint: 8.57.0 graphemer: 1.4.0 ignore: 5.3.2 @@ -8375,7 +8372,7 @@ snapshots: '@typescript-eslint/types': 7.18.0 '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.5.4) '@typescript-eslint/visitor-keys': 7.18.0 - debug: 4.3.7(supports-color@8.1.1) + debug: 4.3.7 eslint: 8.57.0 optionalDependencies: typescript: 5.5.4 @@ -8388,7 +8385,7 @@ snapshots: '@typescript-eslint/types': 7.7.1 '@typescript-eslint/typescript-estree': 7.7.1(typescript@5.5.4) '@typescript-eslint/visitor-keys': 7.7.1 - debug: 4.3.7(supports-color@8.1.1) + debug: 4.3.7 eslint: 8.57.0 optionalDependencies: typescript: 5.5.4 @@ -8431,7 +8428,7 @@ snapshots: dependencies: '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.5.4) '@typescript-eslint/utils': 7.18.0(eslint@8.57.0)(typescript@5.5.4) - debug: 4.3.7(supports-color@8.1.1) + debug: 4.3.7 eslint: 8.57.0 ts-api-utils: 1.3.0(typescript@5.5.4) optionalDependencies: @@ -8443,7 +8440,7 @@ snapshots: dependencies: '@typescript-eslint/typescript-estree': 7.7.1(typescript@5.5.4) '@typescript-eslint/utils': 7.7.1(eslint@8.57.0)(typescript@5.5.4) - debug: 4.3.7(supports-color@8.1.1) + debug: 4.3.7 eslint: 8.57.0 ts-api-utils: 1.3.0(typescript@5.5.4) optionalDependencies: @@ -8491,7 +8488,7 @@ snapshots: dependencies: '@typescript-eslint/types': 7.18.0 '@typescript-eslint/visitor-keys': 7.18.0 - debug: 4.3.7(supports-color@8.1.1) + debug: 4.3.7 globby: 11.1.0 is-glob: 4.0.3 minimatch: 9.0.5 @@ -8506,7 +8503,7 @@ snapshots: dependencies: '@typescript-eslint/types': 7.7.1 '@typescript-eslint/visitor-keys': 7.7.1 - debug: 4.3.7(supports-color@8.1.1) + debug: 4.3.7 globby: 11.1.0 is-glob: 4.0.3 minimatch: 9.0.5 @@ -8637,7 +8634,7 @@ snapshots: agent-base@6.0.2: dependencies: - debug: 4.3.7(supports-color@8.1.1) + debug: 4.3.7 transitivePeerDependencies: - supports-color @@ -9194,6 +9191,10 @@ snapshots: dependencies: ms: 2.1.3 + debug@4.3.7: + dependencies: + ms: 2.1.3 + debug@4.3.7(supports-color@8.1.1): dependencies: ms: 2.1.3 @@ -9493,7 +9494,7 @@ snapshots: eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.5.4))(eslint-plugin-import@2.29.1)(eslint@8.57.0): dependencies: '@nolyfill/is-core-module': 1.0.39 - debug: 4.3.7(supports-color@8.1.1) + debug: 4.3.7 enhanced-resolve: 5.17.1 eslint: 8.57.0 eslint-module-utils: 2.12.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.0) @@ -9659,7 +9660,7 @@ snapshots: ajv: 6.12.6 chalk: 4.1.2 cross-spawn: 7.0.3 - debug: 4.3.7(supports-color@8.1.1) + debug: 4.3.7 doctrine: 3.0.0 escape-string-regexp: 4.0.0 eslint-scope: 7.2.2 @@ -9944,7 +9945,7 @@ snapshots: follow-redirects@1.15.9(debug@4.3.7): optionalDependencies: - debug: 4.3.7(supports-color@8.1.1) + debug: 4.3.7 for-each@0.3.3: dependencies: @@ -10260,7 +10261,7 @@ snapshots: https-proxy-agent@5.0.1: dependencies: agent-base: 6.0.2 - debug: 4.3.7(supports-color@8.1.1) + debug: 4.3.7 transitivePeerDependencies: - supports-color diff --git a/v-next/hardhat/package.json b/v-next/hardhat/package.json index 79747009a0..f1d35776d2 100644 --- a/v-next/hardhat/package.json +++ b/v-next/hardhat/package.json @@ -97,7 +97,6 @@ "ethereum-cryptography": "^2.2.1", "micro-eth-signer": "^0.13.0", "p-map": "^7.0.2", - "raw-body": "^2.4.1", "semver": "^7.6.3", "solc": "^0.8.27", "tsx": "^4.11.0", diff --git a/v-next/hardhat/src/internal/builtin-plugins/network-manager/json-rpc.ts b/v-next/hardhat/src/internal/builtin-plugins/network-manager/json-rpc.ts index cc6064d46b..b845dba37b 100644 --- a/v-next/hardhat/src/internal/builtin-plugins/network-manager/json-rpc.ts +++ b/v-next/hardhat/src/internal/builtin-plugins/network-manager/json-rpc.ts @@ -74,7 +74,7 @@ export function isJsonRpcRequest(payload: unknown): payload is JsonRpcRequest { return false; } - if (payload.params !== undefined && !Array.isArray(payload.params)) { + if (!Array.isArray(payload.params)) { return false; } diff --git a/v-next/hardhat/src/internal/builtin-plugins/node/json-rpc/handler.ts b/v-next/hardhat/src/internal/builtin-plugins/node/json-rpc/handler.ts index c29b7bd1e8..7fe1f48e0f 100644 --- a/v-next/hardhat/src/internal/builtin-plugins/node/json-rpc/handler.ts +++ b/v-next/hardhat/src/internal/builtin-plugins/node/json-rpc/handler.ts @@ -1,5 +1,5 @@ import type { - EIP1193Provider, + EthereumProvider, FailedJsonRpcResponse, JsonRpcRequest, JsonRpcResponse, @@ -7,8 +7,6 @@ import type { import type { IncomingMessage, ServerResponse } from "node:http"; import type WebSocket from "ws"; -import getRawBody from "raw-body"; - import { InternalError, InvalidJsonInputError, @@ -22,9 +20,9 @@ import { import { ProviderError } from "../../network-manager/provider-errors.js"; export class JsonRpcHandler { - readonly #provider: EIP1193Provider; + readonly #provider: EthereumProvider; - constructor(provider: EIP1193Provider) { + constructor(provider: EthereumProvider) { this.#provider = provider; } @@ -46,18 +44,18 @@ export class JsonRpcHandler { return; } + // NOTE: EthereumProvider currently doesn't support batch requests. Thus, + // the following code block could be safely removed. if (Array.isArray(jsonHttpRequest)) { const responses = await Promise.all( - jsonHttpRequest.map((singleReq: any) => - this.#handleSingleRequest(singleReq), - ), + jsonHttpRequest.map((singleReq: any) => this.#handleRequest(singleReq)), ); this.#sendResponse(res, responses); return; } - const rpcResp = await this.#handleSingleRequest(jsonHttpRequest); + const rpcResp = await this.#handleRequest(jsonHttpRequest); this.#sendResponse(res, rpcResp); }; @@ -98,11 +96,9 @@ export class JsonRpcHandler { rpcResp = Array.isArray(rpcReq) ? await Promise.all( - rpcReq.map((req) => - this.#handleSingleWsRequest(req, subscriptions), - ), + rpcReq.map((req) => this.#handleWsRequest(req, subscriptions)), ) - : await this.#handleSingleWsRequest(rpcReq, subscriptions); + : await this.#handleWsRequest(rpcReq, subscriptions); } catch (error) { rpcResp = _handleError(error); } @@ -146,7 +142,7 @@ export class JsonRpcHandler { res.end(JSON.stringify(rpcResp)); } - async #handleSingleRequest(req: JsonRpcRequest): Promise { + async #handleRequest(req: JsonRpcRequest): Promise { if (!isJsonRpcRequest(req)) { return _handleError(new InvalidRequestError("Invalid request")); } @@ -155,7 +151,16 @@ export class JsonRpcHandler { let rpcResp: JsonRpcResponse | undefined; try { - rpcResp = await this.#handleRequest(rpcReq); + const result = await this.#provider.request({ + method: req.method, + params: req.params, + }); + + rpcResp = { + jsonrpc: "2.0", + id: req.id, + result, + }; } catch (error) { rpcResp = _handleError(error); } @@ -166,18 +171,13 @@ export class JsonRpcHandler { rpcResp = _handleError(new InternalError("Internal error")); } - if (rpcReq !== undefined) { - rpcResp.id = rpcReq.id !== undefined ? rpcReq.id : null; - } + rpcResp.id = rpcReq.id !== undefined ? rpcReq.id : null; return rpcResp; } - async #handleSingleWsRequest( - rpcReq: JsonRpcRequest, - subscriptions: string[], - ) { - const rpcResp = await this.#handleSingleRequest(rpcReq); + async #handleWsRequest(rpcReq: JsonRpcRequest, subscriptions: string[]) { + const rpcResp = await this.#handleRequest(rpcReq); // If eth_subscribe was successful, keep track of the subscription id, // so we can cleanup on websocket close. @@ -191,29 +191,17 @@ export class JsonRpcHandler { return rpcResp; } - - readonly #handleRequest = async ( - req: JsonRpcRequest, - ): Promise => { - const result = await this.#provider.request({ - method: req.method, - params: req.params, - }); - - return { - jsonrpc: "2.0", - id: req.id, - result, - }; - }; } const _readJsonHttpRequest = async (req: IncomingMessage): Promise => { let json; try { - const buf = await getRawBody(req); - const text = buf.toString(); + const bytes: number[] = []; + for await (const chunk of req) { + bytes.push(...chunk); + } + const text = new TextDecoder("utf-8").decode(new Uint8Array(bytes)); json = JSON.parse(text); } catch (error) { @@ -252,13 +240,13 @@ const _handleError = (error: any): JsonRpcResponse => { txHash = error.transactionHash; } if (error.data !== undefined) { - if (error.data?.data !== undefined) { + if (error.data.data !== undefined) { returnData = error.data.data; } else { returnData = error.data; } - if (txHash === undefined && error.data?.transactionHash !== undefined) { + if (txHash === undefined && error.data.transactionHash !== undefined) { txHash = error.data.transactionHash; } } @@ -282,6 +270,7 @@ const _handleError = (error: any): JsonRpcResponse => { }; if (txHash !== undefined) { + data.txHash = txHash; } if (returnData !== undefined) { diff --git a/v-next/hardhat/src/internal/builtin-plugins/node/json-rpc/server.ts b/v-next/hardhat/src/internal/builtin-plugins/node/json-rpc/server.ts index bce7c5f3c6..38cf29f697 100644 --- a/v-next/hardhat/src/internal/builtin-plugins/node/json-rpc/server.ts +++ b/v-next/hardhat/src/internal/builtin-plugins/node/json-rpc/server.ts @@ -1,4 +1,4 @@ -import type { EIP1193Provider } from "../../../../types/providers.js"; +import type { EthereumProvider } from "../../../../types/providers.js"; import type { Server } from "node:http"; import type { AddressInfo } from "node:net"; @@ -11,7 +11,7 @@ import { JsonRpcHandler } from "./handler.js"; const log = debug("hardhat:core:tasks:node:json-rpc:server"); -export interface IJsonRpcServer { +export interface JsonRpcServer { listen(): Promise<{ address: string; port: number }>; waitUntilClosed(): Promise; @@ -22,10 +22,10 @@ export interface JsonRpcServerConfig { hostname: string; port: number; - provider: EIP1193Provider; + provider: EthereumProvider; } -export class JsonRpcServer implements IJsonRpcServer { +export class JsonRpcServerImplementation implements JsonRpcServer { readonly #config: JsonRpcServerConfig; readonly #httpServer: Server; readonly #wsServer: WebSocketServer; diff --git a/v-next/hardhat/src/internal/builtin-plugins/node/task-action.ts b/v-next/hardhat/src/internal/builtin-plugins/node/task-action.ts index 2e6f6e5e65..bf31a6bd62 100644 --- a/v-next/hardhat/src/internal/builtin-plugins/node/task-action.ts +++ b/v-next/hardhat/src/internal/builtin-plugins/node/task-action.ts @@ -5,7 +5,7 @@ import { HardhatError } from "@ignored/hardhat-vnext-errors"; import { exists } from "@ignored/hardhat-vnext-utils/fs"; import chalk from "chalk"; -import { JsonRpcServer } from "./json-rpc/server.js"; +import { JsonRpcServerImplementation } from "./json-rpc/server.js"; interface NodeActionArguments { hostname: string; @@ -112,7 +112,7 @@ const nodeAction: NewTaskActionFunction = async ( } } - const server: JsonRpcServer = new JsonRpcServer({ + const server: JsonRpcServerImplementation = new JsonRpcServerImplementation({ hostname, port: args.port, provider, diff --git a/v-next/hardhat/test/internal/builtin-plugins/node/json-rpc/server.ts b/v-next/hardhat/test/internal/builtin-plugins/node/json-rpc/server.ts index bd0a72225a..24db55a4a9 100644 --- a/v-next/hardhat/test/internal/builtin-plugins/node/json-rpc/server.ts +++ b/v-next/hardhat/test/internal/builtin-plugins/node/json-rpc/server.ts @@ -6,7 +6,7 @@ import { before, describe, it } from "node:test"; import { exists } from "@ignored/hardhat-vnext-utils/fs"; import { HttpProvider } from "../../../../../src/internal/builtin-plugins/network-manager/http-provider.js"; -import { JsonRpcServer } from "../../../../../src/internal/builtin-plugins/node/json-rpc/server.js"; +import { JsonRpcServerImplementation } from "../../../../../src/internal/builtin-plugins/node/json-rpc/server.js"; import { createHardhatRuntimeEnvironment } from "../../../../../src/internal/hre-intialization.js"; describe("JSON-RPC server", function () { @@ -21,7 +21,7 @@ describe("JSON-RPC server", function () { const port = 8545; const connection = await hre.network.connect(); - const server = new JsonRpcServer({ + const server = new JsonRpcServerImplementation({ hostname, port, provider: connection.provider,