hardhat
Version:
Hardhat is an extensible developer tool that helps smart contract developers increase productivity by reliably bringing together the tools they want.
305 lines (254 loc) • 8.12 kB
text/typescript
import type {
EthereumProvider,
FailedJsonRpcResponse,
JsonRpcRequest,
JsonRpcResponse,
} from "../../../../types/providers.js";
import type { IncomingMessage, ServerResponse } from "node:http";
import type WebSocket from "ws";
import { ensureError } from "@nomicfoundation/hardhat-utils/error";
import { isObject } from "@nomicfoundation/hardhat-utils/lang";
import {
isJsonRpcRequest,
isJsonRpcResponse,
isSuccessfulJsonRpcResponse,
} from "../../network-manager/json-rpc.js";
import {
InternalError,
InvalidJsonInputError,
InvalidRequestError,
ProviderError,
} from "../../network-manager/provider-errors.js";
export class JsonRpcHandler {
readonly #provider: EthereumProvider;
constructor(provider: EthereumProvider) {
this.#provider = provider;
}
public handleHttp = async (
req: IncomingMessage,
res: ServerResponse,
): Promise<void> => {
this.#setCorsHeaders(res);
if (req.method === "OPTIONS") {
this.#sendEmptyResponse(res);
return;
}
let jsonHttpRequest: unknown;
try {
jsonHttpRequest = await _readJsonHttpRequest(req);
} catch (error) {
ensureError(error);
this.#sendResponse(res, _handleError(error));
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: unknown) =>
this.#handleRequest(singleReq),
),
);
this.#sendResponse(res, responses);
return;
}
const rpcResp = await this.#handleRequest(jsonHttpRequest);
this.#sendResponse(res, rpcResp);
};
public handleWs = async (ws: WebSocket): Promise<void> => {
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) {
ensureError(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.#handleWsRequest(req, subscriptions)),
)
: await this.#handleWsRequest(rpcReq, subscriptions);
} catch (error) {
ensureError(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 #handleRequest(payload: unknown): Promise<JsonRpcResponse> {
if (!isObject(payload)) {
return _handleError(new InvalidRequestError());
}
const maybeReq = {
...payload,
params: payload.params ?? [],
};
if (!isJsonRpcRequest(maybeReq)) {
return _handleError(new InvalidRequestError());
}
const rpcReq: JsonRpcRequest = maybeReq;
let rpcResp: JsonRpcResponse | undefined;
try {
const result = await this.#provider.request({
method: rpcReq.method,
params: rpcReq.params,
});
rpcResp = {
jsonrpc: "2.0",
id: rpcReq.id,
result,
};
} catch (error) {
ensureError(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());
}
rpcResp.id = rpcReq.id !== undefined ? rpcReq.id : null;
return rpcResp;
}
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.
if (
rpcReq.method === "eth_subscribe" &&
isSuccessfulJsonRpcResponse(rpcResp) &&
typeof rpcResp.result === "string"
) {
subscriptions.push(rpcResp.result);
}
return rpcResp;
}
}
const _readJsonHttpRequest = async (req: IncomingMessage): Promise<unknown> => {
let json: unknown;
try {
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) {
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: Error): JsonRpcResponse => {
// In case of non-hardhat error, treat it as internal and associate the appropriate error code.
if (!ProviderError.isProviderError(error)) {
error = new InternalError(undefined, error);
}
const response: FailedJsonRpcResponse = {
jsonrpc: "2.0",
id: null,
error: {
code:
"code" in error && typeof error.code === "number"
? error.code
: InternalError.CODE,
message: error.message,
data: {
message: error.message,
txHash: extractTxHash(error),
data: extractReturnData(error),
},
},
};
return response;
};
function extractTxHash(error: Error): string | undefined {
if ("transactionHash" in error && typeof error.transactionHash === "string") {
return error.transactionHash;
}
if (
"data" in error &&
isObject(error.data) &&
typeof error.data.transactionHash === "string"
) {
return error.data.transactionHash;
}
}
function extractReturnData(error: Error): string | undefined {
if (!("data" in error)) {
return undefined;
}
if (typeof error.data === "string") {
return error.data;
}
if (isObject(error.data) && typeof error.data.data === "string") {
return error.data.data;
}
}