diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 90927f344a..69e8182c1b 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 @@ -1547,11 +1553,14 @@ importers: specifier: ^4.1.4 version: 4.1.12 '@types/node': - specifier: ^20.14.9 - version: 20.17.1 + specifier: ^22.10.0 + version: 22.10.0 '@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) @@ -3429,6 +3438,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==} @@ -3480,6 +3492,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==} @@ -6577,6 +6592,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'} @@ -8098,7 +8116,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: @@ -8106,11 +8124,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.8.5 '@types/chai-as-promised@7.1.8': dependencies: @@ -8126,7 +8144,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: @@ -8138,16 +8156,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.8.5 '@types/glob@7.2.0': dependencies: '@types/minimatch': 5.1.2 - '@types/node': 18.19.59 + '@types/node': 22.8.5 '@types/istanbul-lib-coverage@2.0.6': {} @@ -8157,7 +8175,7 @@ snapshots: '@types/keccak@3.0.5': dependencies: - '@types/node': 18.19.59 + '@types/node': 22.8.5 '@types/lodash.clonedeep@4.5.9': dependencies: @@ -8193,6 +8211,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 @@ -8205,7 +8227,7 @@ snapshots: '@types/pbkdf2@3.1.2': dependencies: - '@types/node': 20.17.1 + '@types/node': 22.8.5 '@types/prettier@2.7.3': {} @@ -8213,14 +8235,14 @@ snapshots: '@types/readable-stream@2.3.15': dependencies: - '@types/node': 20.17.1 + '@types/node': 22.8.5 safe-buffer: 5.1.2 '@types/resolve@1.20.6': {} '@types/secp256k1@4.0.6': dependencies: - '@types/node': 20.17.1 + '@types/node': 22.8.5 '@types/semver@6.2.7': {} @@ -8243,11 +8265,15 @@ snapshots: '@types/ws@7.4.7': dependencies: - '@types/node': 18.19.59 + '@types/node': 22.8.5 + + '@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: @@ -11818,6 +11844,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 8901fa0e1d..953fbad655 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 = { @@ -135,7 +136,7 @@ Please double check whether you have multiple versions of the same plugin instal ENV_VAR_NOT_FOUND: { number: 7, messageTemplate: `Configuration Variable '{name}' not found. - + You can define it using a plugin like hardhat-keystore, or set it as an environment variable.`, websiteTitle: "Configuration variable not found", websiteDescription: `A configuration variable was expected to be set as an environment variable, but it wasn't.`, @@ -1224,4 +1225,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 aa7df3401b..d41342f7a4 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.12.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/helpers.ts b/v-next/hardhat/src/internal/builtin-plugins/node/helpers.ts new file mode 100644 index 0000000000..e4e8248064 --- /dev/null +++ b/v-next/hardhat/src/internal/builtin-plugins/node/helpers.ts @@ -0,0 +1,85 @@ +import type { + EdrNetworkAccountConfig, + EdrNetworkAccountsConfig, + GenesisAccount, +} from "../../../types/config.js"; + +import { + bytesToHexString, + hexStringToBytes, +} from "@ignored/hardhat-vnext-utils/hex"; +import chalk from "chalk"; +import { addr } from "micro-eth-signer"; + +import { derivePrivateKeys } from "../network-manager/json-rpc-request-modifiers/accounts/derive-private-keys.js"; + +export function normalizeEdrNetworkConfigAccounts( + accounts: EdrNetworkAccountsConfig, +): EdrNetworkAccountConfig[] { + const normalizedAccounts: EdrNetworkAccountConfig[] = []; + + if (accounts !== undefined) { + if (Array.isArray(accounts)) { + normalizedAccounts.push(...accounts); + } else if (accounts !== undefined) { + const privateKeys = derivePrivateKeys( + accounts.mnemonic, + accounts.path, + accounts.initialIndex, + accounts.count, + accounts.passphrase, + ); + for (const privateKey of privateKeys) { + normalizedAccounts.push({ + privateKey: bytesToHexString(privateKey), + balance: accounts.accountsBalance, + }); + } + } + } + + return normalizedAccounts; +} + +export function printEdrNetworkConfigAccounts( + accounts: Array, +): void { + if (accounts.length === 0) { + return; + } + + console.log("Accounts"); + console.log("========"); + + // NOTE: In v2, we were printing the warning only if the default config was used. + console.log(); + printPublicPrivateKeysWarning(); + console.log(); + + for (const [index, account] of accounts.entries()) { + const address = addr + .fromPrivateKey(hexStringToBytes(account.privateKey)) + .toLowerCase(); + const balance = (BigInt(account.balance) / 10n ** 18n).toString(10); + + console.log(`Account #${index}: ${address} (${balance} ETH)`); + // TODO: Should we print the private key as well? + // console.log(`Private Key: ${account.privateKey}`); + + console.log(); + } + + // NOTE: In v2, we were printing the warning only if the default config was used. + printPublicPrivateKeysWarning(); + console.log(); +} + +// NOTE: In v2, we were printing the warning only if the default config was used. +// Because of that we were certain that the printed accounts were publicly known. +function printPublicPrivateKeysWarning(): void { + console.log( + chalk.bold( + "WARNING: Funds sent on live network to accounts with publicly known private keys WILL BE LOST.", + ), + ); +} 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..cf2c5b6955 --- /dev/null +++ b/v-next/hardhat/src/internal/builtin-plugins/node/json-rpc/handler.ts @@ -0,0 +1,317 @@ +import type { + EIP1193Provider, + FailedJsonRpcResponse, + JsonRpcRequest, + JsonRpcResponse, +} from "../../../../types/providers.js"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import type WebSocket from "ws"; + +import debug from "debug"; +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"; + +const log = debug("hardhat:core:tasks:node:json-rpc:handler"); + +export class JsonRpcHandler { + readonly #provider: EIP1193Provider; + + constructor(provider: EIP1193Provider) { + this.#provider = provider; + } + + public handleHttp = async ( + req: IncomingMessage, + res: ServerResponse, + ): Promise => { + log(`Handling HTTP request: ${req.method} ${req.url}`); + + 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 => { + log("Handling WebSocket connection"); + + 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) => { + log(`Handling WebSocket message: ${msg}`); + + 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", () => { + log("Handling WebSocket 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) { + log("Sending empty response"); + + res.writeHead(200); + res.end(); + } + + #setCorsHeaders(res: ServerResponse) { + log("Setting CORS headers"); + + 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[], + ) { + log(`Sending response: ${JSON.stringify(rpcResp, undefined, 2)}`); + + res.statusCode = 200; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify(rpcResp)); + } + + async #handleSingleRequest(req: JsonRpcRequest): Promise { + log(`Handling single request: ${JSON.stringify(req, undefined, 2)}`); + + 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; + } + + // NOTE: In v2, the subscriptions were strings + async #handleSingleWsRequest( + rpcReq: JsonRpcRequest, + subscriptions: unknown[], + ) { + log(`Handling WebSocket request: ${JSON.stringify(rpcReq, undefined, 2)}`); + + 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) + ) { + subscriptions.push(rpcResp.result); + } + + return rpcResp; + } + + readonly #handleRequest = async ( + req: JsonRpcRequest, + ): Promise => { + log(`Handling request: ${JSON.stringify(req, undefined, 2)}`); + + 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..92ef9e81b6 --- /dev/null +++ b/v-next/hardhat/src/internal/builtin-plugins/node/task-action.ts @@ -0,0 +1,149 @@ +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 debug from "debug"; + +import { printEdrNetworkConfigAccounts } from "./helpers.js"; +import { JsonRpcServer } from "./json-rpc/server.js"; + +const log = debug("hardhat:core:tasks:node:task-action"); + +interface NodeActionArguments { + hostname: string; + port: number; + chainType: string; + chainId: number; + fork: string; + forkBlockNumber: number; +} + +const nodeAction: NewTaskActionFunction = async ( + args, + hre, +) => { + // NOTE: In v2, we would wrap the entire task in a try/catch block and wrap + // non-Hardhat errors that had messages in a HardhatErrror. + + 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 + const { networkConfig, 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(); + + // TODO: Do we want to port compilation output watching from v2? + + console.log( + chalk.green( + `Started HTTP and WebSocket JSON-RPC server at http://${actualHostname}:${actualPort}/`, + ), + ); + + console.log(); + + log(networkConfig); + + // NOTE: We print the genesis accounts here. Is that correct? + if (networkConfig.type === "edr") { + printEdrNetworkConfigAccounts(networkConfig.genesisAccounts); + } + + await server.waitUntilClosed(); +}; + +export default nodeAction;