Skip to content

Commit

Permalink
new handlers logic + chain-id handler
Browse files Browse the repository at this point in the history
  • Loading branch information
ChristopherDedominici committed Nov 27, 2024
1 parent 3ccca1a commit 05ce4ca
Show file tree
Hide file tree
Showing 9 changed files with 484 additions and 24 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type {
JsonRpcRequest,
JsonRpcResponse,
} from "../../../../types/providers.js";
import type { RequestHandler } from "../request-handlers/types.js";
import type {
HookContext,
NetworkHooks,
Expand All @@ -11,14 +12,18 @@ import type {
NetworkConnection,
} from "@ignored/hardhat-vnext/types/network";

import { JsonRpcRequestModifier } from "../json-rpc-request-modifiers/json-rpc-request-modifier.js";
import { deepClone } from "@ignored/hardhat-vnext-utils/lang";

import { isJsonRpcResponse } from "../json-rpc.js";
import { createHandlersArray } from "../request-handlers/hanlders-array.js";

export default async (): Promise<Partial<NetworkHooks>> => {
// This map is necessary because Hardhat V3 supports multiple network connections, requiring us to track them
// to apply the appropriate modifiers to each request.
// When a connection is closed, it is removed from the map. Refer to "closeConnection" at the end of the file.
const jsonRpcRequestModifiers: Map<number, JsonRpcRequestModifier> =
new Map();
// This map is essential for managing multiple network connections in Hardhat V3.
// Since Hardhat V3 supports multiple connections, we use this map to track each one
// and associate it with the corresponding handlers array.
// When a connection is closed, its associated handlers array is removed from the map.
// See the "closeConnection" function at the end of the file for more details.
const requestHandlersPerConnection: Map<number, RequestHandler[]> = new Map();

const handlers: Partial<NetworkHooks> = {
async onRequest<ChainTypeT extends ChainType | string>(
Expand All @@ -31,31 +36,30 @@ export default async (): Promise<Partial<NetworkHooks>> => {
nextJsonRpcRequest: JsonRpcRequest,
) => Promise<JsonRpcResponse>,
) {
let jsonRpcRequestModifier = jsonRpcRequestModifiers.get(
let requestHandlers = requestHandlersPerConnection.get(
networkConnection.id,
);

if (jsonRpcRequestModifier === undefined) {
jsonRpcRequestModifier = new JsonRpcRequestModifier(networkConnection);

jsonRpcRequestModifiers.set(
networkConnection.id,
jsonRpcRequestModifier,
);
if (requestHandlers === undefined) {
requestHandlers = createHandlersArray(networkConnection);
requestHandlersPerConnection.set(networkConnection.id, requestHandlers);
}

const newJsonRpcRequest =
await jsonRpcRequestModifier.createModifiedJsonRpcRequest(
jsonRpcRequest,
);
// We clone the request to avoid interfering with other hook handlers that
// might be using the original request.
let request = await deepClone(jsonRpcRequest);

for (const handler of requestHandlers) {
const newRequestOrResponse = await handler.handle(request);

const res = await jsonRpcRequestModifier.getResponse(jsonRpcRequest);
if (isJsonRpcResponse(newRequestOrResponse)) {
return newRequestOrResponse;
}

if (res !== null) {
return res;
request = newRequestOrResponse;
}

return next(context, networkConnection, newJsonRpcRequest);
return next(context, networkConnection, request);
},

async closeConnection<ChainTypeT extends ChainType | string>(
Expand All @@ -66,8 +70,8 @@ export default async (): Promise<Partial<NetworkHooks>> => {
nextNetworkConnection: NetworkConnection<ChainTypeT>,
) => Promise<void>,
): Promise<void> {
if (jsonRpcRequestModifiers.has(networkConnection.id) === true) {
jsonRpcRequestModifiers.delete(networkConnection.id);
if (requestHandlersPerConnection.has(networkConnection.id) === true) {
requestHandlersPerConnection.delete(networkConnection.id);
}

return next(context, networkConnection);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import type { EthereumProvider } from "../../../../../../types/providers.js";

import { assertHardhatInvariant } from "@ignored/hardhat-vnext-errors";
import {
hexStringToNumber,
isPrefixedHexString,
} from "@ignored/hardhat-vnext-utils/hex";

/**
* This class is responsible for retrieving the chain ID of the network.
* It uses the provider to fetch the chain ID via two methods: 'eth_chainId' and,
* as a fallback, 'net_version' if the first one fails. The chain ID is cached
* after being retrieved to avoid redundant requests.
*/
export class ChainId {
readonly #provider: EthereumProvider;

#chainId: number | undefined;

constructor(provider: EthereumProvider) {
this.#provider = provider;
}

public async getChainId(): Promise<number> {
if (this.#chainId === undefined) {
try {
this.#chainId = await this.#getChainIdFromEthChainId();
} catch {
// If eth_chainId fails we default to net_version
this.#chainId = await this.#getChainIdFromEthNetVersion();
}
}

return this.#chainId;
}

async #getChainIdFromEthChainId(): Promise<number> {
const id = await this.#provider.request({
method: "eth_chainId",
});

assertHardhatInvariant(typeof id === "string", "id should be a string");

return hexStringToNumber(id);
}

async #getChainIdFromEthNetVersion(): Promise<number> {
const id = await this.#provider.request({
method: "net_version",
});

assertHardhatInvariant(typeof id === "string", "id should be a string");

// There's a node returning this as decimal instead of QUANTITY.
// TODO: from V2 - Document here which node does that
return isPrefixedHexString(id) ? hexStringToNumber(id) : parseInt(id, 10);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import type {
EthereumProvider,
JsonRpcRequest,
JsonRpcResponse,
} from "../../../../../../types/providers.js";
import type { RequestHandler } from "../../types.js";

import { HardhatError } from "@ignored/hardhat-vnext-errors";

import { ChainId } from "./chain-id.js";

/**
* This class validates that the current provider's chain ID matches
* an expected value. If the actual chain ID differs from the expected one, it throws a
* HardhatError to signal a network configuration mismatch. Once validated, further checks
* are skipped to avoid redundant validations.
*/
export class ChainIdValidatorHandler implements RequestHandler {
readonly #chainId: ChainId;
readonly #expectedChainId: number;
#alreadyValidated = false;

constructor(provider: EthereumProvider, expectedChainId: number) {
this.#chainId = new ChainId(provider);
this.#expectedChainId = expectedChainId;
}

public async handle(
jsonRpcRequest: JsonRpcRequest,
): Promise<JsonRpcRequest | JsonRpcResponse> {
if (
jsonRpcRequest.method === "eth_chainId" ||
jsonRpcRequest.method === "net_version"
) {
return jsonRpcRequest;
}

if (this.#alreadyValidated) {
return jsonRpcRequest;
}

const actualChainId = await this.#chainId.getChainId();

if (actualChainId !== this.#expectedChainId) {
throw new HardhatError(
HardhatError.ERRORS.NETWORK.INVALID_GLOBAL_CHAIN_ID,
{
configChainId: this.#expectedChainId,
connectionChainId: actualChainId,
},
);
}

this.#alreadyValidated = true;

return jsonRpcRequest;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type { RequestHandler } from "./types.js";
import type {
ChainType,
NetworkConnection,
} from "../../../../types/network.js";

import { ChainIdValidatorHandler } from "./handlers/chain-id/handler.js";

// TODO: finish docs
/**
*
* This function returns an handlers array based on the values in the networkConfig and....
* The order of the handlers, if all are present, is: chain handler, gas handler and accounts handler.
* The order is important to get a correct result when the handler are executed
*/
export function createHandlersArray<ChainTypeT extends ChainType | string>(
networkConnection: NetworkConnection<ChainTypeT>,
): RequestHandler[] {
const requestHandlers = [];

if (networkConnection.networkConfig.type === "http") {
if (networkConnection.networkConfig.chainId !== undefined) {
requestHandlers.push(
new ChainIdValidatorHandler(
networkConnection.provider,
networkConnection.networkConfig.chainId,
),
);
}
}

return requestHandlers;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type {
JsonRpcRequest,
JsonRpcResponse,
} from "../../../../types/providers.js";

/**
* Common interface for request handlers, which can either return a new
* modified request, or a response.
*
* If they return a request, it's passed to the next handler, or to the "next"
* function if there are no more handlers.
*
* If they return a response, it's returned immediately.
*
*/
export interface RequestHandler {
handle(
jsonRpcRequest: JsonRpcRequest,
): Promise<JsonRpcRequest | JsonRpcResponse>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { describe, it } from "node:test";

import { createMockedNetworkHre } from "./hooks-mock.js";

// Test that the request and its additional sub-request (when present)
// are correctly modified in the "onRequest" hook handler.
// These tests simulate a real scenario where the user calls "await connection.provider.request(jsonRpcRequest)".
describe("request-handlers - e2e", () => {
it("should successfully executes all the handlers", async () => {
const hre = await createMockedNetworkHre(
{
networks: {
localhost: {
type: "http",
url: "http://localhost:8545",
chainId: 1,
},
},
},
{
eth_chainId: "0x1",
},
);

// Use the localhost network for these tests because the modifier is only
// applicable to HTTP networks. EDR networks do not require this modifier.
const connection = await hre.network.connect("localhost");

await connection.provider.request({
method: "eth_sendTransaction",
params: [],
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import type {
EIP1193Provider,
JsonRpcRequest,
JsonRpcResponse,
RequestArguments,
} from "../../../../../src/types/providers.js";

import EventEmitter from "node:events";

// This mock is used in the unit tests to simulate the return value of the "request" method
export class EthereumMockedProvider
extends EventEmitter
implements EIP1193Provider
{
// Record<methodName, value>
readonly #returnValues: Record<string, any> = {};

readonly #latestParams: Record<string, RequestArguments["params"]> = {};

readonly #numberOfCalls: { [method: string]: number } = {};

// If a lambda is passed as value, it's return value is used.
public setReturnValue(method: string, value: any): void {
this.#returnValues[method] = value;
}

public getNumberOfCalls(method: string): number {
if (this.#numberOfCalls[method] === undefined) {
return 0;
}

return this.#numberOfCalls[method];
}

public getLatestParams(method: string): any {
return this.#latestParams[method];
}

public getTotalNumberOfCalls(): number {
return Object.values(this.#numberOfCalls).reduce((p, c) => p + c, 0);
}

public async request({
method,
params = [],
}: RequestArguments): Promise<any> {
// stringify the params to make sure they are serializable
JSON.stringify(params);

this.#latestParams[method] = params;

if (this.#numberOfCalls[method] === undefined) {
this.#numberOfCalls[method] = 1;
} else {
this.#numberOfCalls[method] += 1;
}

let ret = this.#returnValues[method];

if (ret instanceof Function) {
ret = ret();
}

return ret;
}

public send(_method: string, _params?: unknown[]): Promise<unknown> {
return Promise.resolve(null);
}

public sendAsync(
_jsonRpcRequest: JsonRpcRequest,
_callback: (error: any, jsonRpcResponse: JsonRpcResponse) => void,
): void {}

public async close(): Promise<void> {}
}
Loading

0 comments on commit 05ce4ca

Please sign in to comment.