diff --git a/packages/example-project/hardhat.config.ts b/packages/example-project/hardhat.config.ts index e8b315b..a559283 100644 --- a/packages/example-project/hardhat.config.ts +++ b/packages/example-project/hardhat.config.ts @@ -4,7 +4,10 @@ import myPlugin from "hardhat-my-plugin"; export default { plugins: [myPlugin], solidity: "0.8.29", - myConfig: { - greeting: "Hola", + networks: { + default: { + type: "edr-simulated", + myAccountIndex: 1, + }, }, } satisfies HardhatUserConfig; diff --git a/packages/example-project/scripts/my-account-example.ts b/packages/example-project/scripts/my-account-example.ts new file mode 100644 index 0000000..53133e4 --- /dev/null +++ b/packages/example-project/scripts/my-account-example.ts @@ -0,0 +1,5 @@ +import { network } from "hardhat"; + +const connection = await network.connect(); + +console.log("connection.myAccount", connection.myAccount); diff --git a/packages/plugin/src/config.ts b/packages/plugin/src/config.ts index 9014146..aafeee2 100644 --- a/packages/plugin/src/config.ts +++ b/packages/plugin/src/config.ts @@ -2,6 +2,8 @@ import { HardhatUserConfig } from "hardhat/config"; import { HardhatConfig } from "hardhat/types/config"; import { HardhatUserConfigValidationError } from "hardhat/types/hooks"; +const DEFAULT_MY_ACCOUNT_INDEX = 0; + /** * This function validates the parts of the HardhatUserConfig that are relevant * to the plugin. @@ -14,34 +16,35 @@ import { HardhatUserConfigValidationError } from "hardhat/types/hooks"; export async function validatePluginConfig( userConfig: HardhatUserConfig, ): Promise { - if (userConfig.myConfig === undefined) { + if ( + userConfig.networks === undefined || + typeof userConfig.networks !== "object" + ) { + // If there's no networks field or it's invalid, we don't validate anything + // in this plugin return []; } - if (typeof userConfig.myConfig !== "object") { - return [ - { - path: ["myConfig"], - message: "Expected an object with an optional greeting.", - }, - ]; - } - - const greeting = userConfig.myConfig?.greeting; - if (greeting === undefined) { - return []; - } + const errors = []; + for (const [networkName, networkConfig] of Object.entries( + userConfig.networks, + )) { + if (networkConfig.myAccountIndex === undefined) { + continue; + } - if (typeof greeting !== "string" || greeting.length === 0) { - return [ - { - path: ["myConfig", "greeting"], - message: "Expected a non-empty string.", - }, - ]; + if ( + typeof networkConfig.myAccountIndex !== "number" || + networkConfig.myAccountIndex < 0 + ) { + errors.push({ + path: ["networks", networkName, "myAccountIndex"], + message: "Expected a non-negative number.", + }); + } } - return []; + return errors; } /** @@ -59,11 +62,23 @@ export async function resolvePluginConfig( userConfig: HardhatUserConfig, partiallyResolvedConfig: HardhatConfig, ): Promise { - const greeting = userConfig.myConfig?.greeting ?? "Hello"; - const myConfig = { greeting }; + const networks: HardhatConfig["networks"] = {}; + + for (const [networkName, networkConfig] of Object.entries( + partiallyResolvedConfig.networks, + )) { + const myAccountIndex = + userConfig.networks?.[networkName]?.myAccountIndex ?? + DEFAULT_MY_ACCOUNT_INDEX; + + networks[networkName] = { + ...networkConfig, + myAccountIndex, + }; + } return { ...partiallyResolvedConfig, - myConfig, + networks, }; } diff --git a/packages/plugin/src/hooks/network.ts b/packages/plugin/src/hooks/network.ts index a0e8285..969a9a0 100644 --- a/packages/plugin/src/hooks/network.ts +++ b/packages/plugin/src/hooks/network.ts @@ -1,3 +1,4 @@ +import { HardhatPluginError } from "hardhat/plugins"; import type { HookContext, NetworkHooks } from "hardhat/types/hooks"; import { ChainType, NetworkConnection } from "hardhat/types/network"; @@ -11,16 +12,23 @@ export default async (): Promise> => { ): Promise> { const connection = await next(context); - console.log("Connection created with ID", connection.id); + // Get the accounts from the connection + const accounts: string[] = await connection.provider.request({ + method: "eth_accounts", + }); - return connection; - }, - async onRequest(context, networkConnection, jsonRpcRequest, next) { - console.log( - `Request from connection ${networkConnection.id} is being processed — Method: ${jsonRpcRequest.method}`, - ); + const myAccountIndex = connection.networkConfig.myAccountIndex; + + if (accounts.length <= myAccountIndex) { + throw new HardhatPluginError( + `hardhat-plugin-template`, + `Invalid index ${myAccountIndex} for myAccount when connecting to network ${connection.networkName}`, + ); + } - return next(context, networkConnection, jsonRpcRequest); + connection.myAccount = accounts[myAccountIndex]; + + return connection; }, }; diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index 7ae1fd0..6d65eb9 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -11,14 +11,14 @@ const plugin: HardhatPlugin = { network: () => import("./hooks/network.js"), }, tasks: [ - task("my-task", "Prints a greeting.") + task("my-account", "Prints your account.") .addOption({ - name: "who", - description: "Who is receiving the greeting.", + name: "title", + description: "The title to use before printing the account.", type: ArgumentType.STRING, - defaultValue: "Hardhat", + defaultValue: "My account:", }) - .setAction(() => import("./tasks/my-task.js")) + .setAction(() => import("./tasks/my-account.js")) .build(), ], }; diff --git a/packages/plugin/src/tasks/my-account.ts b/packages/plugin/src/tasks/my-account.ts new file mode 100644 index 0000000..9f28228 --- /dev/null +++ b/packages/plugin/src/tasks/my-account.ts @@ -0,0 +1,14 @@ +import { HardhatRuntimeEnvironment } from "hardhat/types/hre"; + +interface MyAccountTaskArguments { + title: string; +} + +export default async function ( + taskArguments: MyAccountTaskArguments, + hre: HardhatRuntimeEnvironment, +) { + const conn = await hre.network.connect(); + console.log(taskArguments.title); + console.log(conn.myAccount); +} diff --git a/packages/plugin/src/tasks/my-task.ts b/packages/plugin/src/tasks/my-task.ts deleted file mode 100644 index b09a5b2..0000000 --- a/packages/plugin/src/tasks/my-task.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { HardhatRuntimeEnvironment } from "hardhat/types/hre"; - -interface MyTaskTaskArguments { - who: string; -} - -export default async function ( - taskArguments: MyTaskTaskArguments, - hre: HardhatRuntimeEnvironment, -) { - console.log(`${hre.config.myConfig.greeting}, ${taskArguments.who}!`); -} diff --git a/packages/plugin/src/type-extensions.ts b/packages/plugin/src/type-extensions.ts index 5621de2..74749f6 100644 --- a/packages/plugin/src/type-extensions.ts +++ b/packages/plugin/src/type-extensions.ts @@ -1,13 +1,26 @@ -import { MyPluginConfig, MyPluginUserConfig } from "./types.js"; - import "hardhat/types/config"; declare module "hardhat/types/config" { - interface HardhatUserConfig { - myConfig?: MyPluginUserConfig; + export interface EdrNetworkUserConfig { + myAccountIndex?: number; + } + + export interface EdrNetworkConfig { + myAccountIndex: number; + } + + export interface HttpNetworkUserConfig { + myAccountIndex?: number; } - interface HardhatConfig { - myConfig: MyPluginConfig; + export interface HttpNetworkConfig { + myAccountIndex: number; + } +} + +import "hardhat/types/network"; +declare module "hardhat/types/network" { + export interface NetworkConnection { + myAccount: string; } } diff --git a/packages/plugin/src/types.ts b/packages/plugin/src/types.ts deleted file mode 100644 index 8b23adf..0000000 --- a/packages/plugin/src/types.ts +++ /dev/null @@ -1,7 +0,0 @@ -export interface MyPluginUserConfig { - greeting?: string; -} - -export interface MyPluginConfig { - greeting: string; -} diff --git a/packages/plugin/test/config.ts b/packages/plugin/test/config.ts index cd520ab..3cb7279 100644 --- a/packages/plugin/test/config.ts +++ b/packages/plugin/test/config.ts @@ -13,12 +13,12 @@ describe("MyPlugin config", () => { assert.equal(validationErrors.length, 0); }); - it("Should ignore errors in other parts of the config", async () => { + it("Should consider a newtwork without a myAccountIndex as valid", async () => { const validationErrors = await validatePluginConfig({ networks: { foo: { type: "http", - url: "INVALID URL", + url: "http://localhost:8545", }, }, }); @@ -26,18 +26,27 @@ describe("MyPlugin config", () => { assert.equal(validationErrors.length, 0); }); - it("Should accept an empty myConfig object", async () => { + it("Should accept a non-negative myAccountIndex", async () => { const validationErrors = await validatePluginConfig({ - myConfig: {}, + networks: { + foo: { + type: "edr-simulated", + myAccountIndex: 1, + }, + }, }); assert.equal(validationErrors.length, 0); }); - it("Should accept an non-empty greeting", async () => { + it("Should ignore errors in other parts of the config, including the network", async () => { const validationErrors = await validatePluginConfig({ - myConfig: { - greeting: "Hola", + networks: { + foo: { + type: "http", + url: "INVALID", + myAccountIndex: 1, + }, }, }); @@ -46,48 +55,69 @@ describe("MyPlugin config", () => { }); describe("Invalid cases", () => { - // Many invalid cases are type-unsafe, as we have to trick TypeScript into - // allowing something that is invalid - it("Should reject a myConfig field with an invalid type", async () => { + it("Should reject a myAccountIndex field with an invalid type", async () => { const validationErrors = await validatePluginConfig({ - // @ts-expect-error We're intentionally passing a string here - myConfig: "INVALID", + networks: { + foo: { + type: "edr-simulated", + // @ts-expect-error We're intentionally passing a string here + myAccountIndex: "INVALID", + }, + }, }); assert.deepEqual(validationErrors, [ { - path: ["myConfig"], - message: "Expected an object with an optional greeting.", + path: ["networks", "foo", "myAccountIndex"], + message: "Expected a non-negative number.", }, ]); }); - it("Should reject a myConfig field with an invalid greeting", async () => { + it("Should reject a myAccountIndex with a negative value", async () => { const validationErrors = await validatePluginConfig({ - myConfig: { - greeting: 123 as unknown as string, + networks: { + foo: { + type: "edr-simulated", + myAccountIndex: -1, + }, }, }); assert.deepEqual(validationErrors, [ { - path: ["myConfig", "greeting"], - message: "Expected a non-empty string.", + path: ["networks", "foo", "myAccountIndex"], + message: "Expected a non-negative number.", }, ]); }); - it("Should reject a myConfig field with an empty greeting", async () => { + it("Should validate all the networks", async () => { const validationErrors = await validatePluginConfig({ - myConfig: { - greeting: "", + networks: { + foo: { + type: "edr-simulated", + myAccountIndex: -1, + }, + valid: { + type: "edr-simulated", + myAccountIndex: 1, + }, + bar: { + type: "edr-simulated", + myAccountIndex: -2, + }, }, }); assert.deepEqual(validationErrors, [ { - path: ["myConfig", "greeting"], - message: "Expected a non-empty string.", + path: ["networks", "foo", "myAccountIndex"], + message: "Expected a non-negative number.", + }, + { + path: ["networks", "bar", "myAccountIndex"], + message: "Expected a non-negative number.", }, ]); }); @@ -95,49 +125,90 @@ describe("MyPlugin config", () => { }); describe("Config resolution", () => { - // The config resolution is always type-unsafe, as your plugin is extending - // the HardhatConfig type, but the partially resolved config isn't aware of - // your plugin's extensions. You are responsible for ensuring that they are - // defined correctly during the resolution process. - // - // We recommend testing using an artificial partially resolved config, as - // we do here, but taking care that the fields that your resolution logic - // depends on are defined and valid. - - it("Should resolve a config without a myConfig field", async () => { - const userConfig: HardhatUserConfig = {}; - const partiallyResolvedConfig = {} as HardhatConfig; - - const resolvedConfig = await resolvePluginConfig( - userConfig, - partiallyResolvedConfig, - ); - - assert.deepEqual(resolvedConfig.myConfig, { greeting: "Hello" }); - }); + // By the time this plugin's resolution is called, Hardhat has already + // run the base config resolution, including of the networks. This means + // that the partially resolved config already has them, and all we need to + // do is resolve the myAccountIndex field. + + // In these tests we only create the minimum partially resolved config, + // that's not really valid, but that has the fields our resolution needs. + + it("Should resolve the default myAccountIndex of every network that's already partially resolved", async () => { + const userConfig: HardhatUserConfig = { + networks: { + other: { + type: "edr-simulated", + }, + }, + }; - it("Should resolve a config with an empty myConfig field", async () => { - const userConfig: HardhatUserConfig = { myConfig: {} }; - const partiallyResolvedConfig = {} as HardhatConfig; + const partiallyResolvedConfig = { + networks: { + edr: { + type: "edr-simulated", + }, + http: { + type: "http", + url: "http://localhost:8545", + }, + other: { + type: "edr-simulated", + }, + }, + } as unknown as HardhatConfig; const resolvedConfig = await resolvePluginConfig( userConfig, partiallyResolvedConfig, ); - assert.deepEqual(resolvedConfig.myConfig, { greeting: "Hello" }); + assert.deepEqual(resolvedConfig.networks?.edr?.myAccountIndex, 0); + assert.deepEqual(resolvedConfig.networks?.http?.myAccountIndex, 0); + assert.deepEqual(resolvedConfig.networks?.other?.myAccountIndex, 0); }); - it("Should resolve a config using the provided greeting", async () => { - const userConfig: HardhatUserConfig = { myConfig: { greeting: "Hola" } }; - const partiallyResolvedConfig = {} as HardhatConfig; + it("Should resolve the myAccountIndex as provided in the userConfig", async () => { + const userConfig: HardhatUserConfig = { + networks: { + edr: { + type: "edr-simulated", + myAccountIndex: 1, + }, + http: { + type: "http", + url: "http://localhost:8545", + myAccountIndex: 2, + }, + other: { + type: "edr-simulated", + myAccountIndex: 0, + }, + }, + }; + + const partiallyResolvedConfig = { + networks: { + edr: { + type: "edr-simulated", + }, + http: { + type: "http", + url: "http://localhost:8545", + }, + other: { + type: "edr-simulated", + }, + }, + } as unknown as HardhatConfig; const resolvedConfig = await resolvePluginConfig( userConfig, partiallyResolvedConfig, ); - assert.deepEqual(resolvedConfig.myConfig, { greeting: "Hola" }); + assert.deepEqual(resolvedConfig.networks?.edr?.myAccountIndex, 1); + assert.deepEqual(resolvedConfig.networks?.http?.myAccountIndex, 2); + assert.deepEqual(resolvedConfig.networks?.other?.myAccountIndex, 0); }); }); }); diff --git a/packages/plugin/test/example-tests.ts b/packages/plugin/test/example-tests.ts deleted file mode 100644 index 395b156..0000000 --- a/packages/plugin/test/example-tests.ts +++ /dev/null @@ -1,64 +0,0 @@ -import assert from "node:assert/strict"; -import { describe, it } from "node:test"; - -import path from "node:path"; -import { createHardhatRuntimeEnvironment } from "hardhat/hre"; -import MyPlugin from "../src/index.js"; -import { createFixtureProjectHRE } from "./helpers/fixture-projects.js"; - -describe("MyPlugin tests", () => { - describe("Test using a fixture project", async () => { - it("Should define my-task", async () => { - const hre = await createFixtureProjectHRE("base-project"); - - const myTask = hre.tasks.getTask("my-task"); - assert.notEqual( - myTask, - undefined, - "my-task should be defined because we loaded the plugin", - ); - - // You can use any feature of Hardhat to build your tests, for example, - // running the task and connecting to a new edr-simulated network - await myTask.run(); - - const conn = await hre.network.connect(); - assert.equal( - await conn.provider.request({ method: "eth_blockNumber" }), - "0x0", - "The simulated chain is new, so it should be empty", - ); - }); - }); - - describe("Test creating a new HRE with an inline config", async () => { - it("Should be able to load the plugin", async () => { - // You can also create a new HRE without a fixture project, including - // a custom config. - // - // In this case we don't provide a fixture project, nor a config path, just - // a config object. - // - // You can customize the config object here, including adding new plugins. - const hre = await createHardhatRuntimeEnvironment({ - plugins: [MyPlugin], - myConfig: { - greeting: "Hola", - }, - }); - - assert.equal(hre.config.myConfig.greeting, "Hola"); - - // The config path is undefined because we didn't provide it to - // createHardhatRuntimeEnvironment. See its documentation for more info. - assert.equal(hre.config.paths.config, undefined); - - // The root path is the directory containing the closest package.json to - // the CWD, if none is provided. - assert.equal( - hre.config.paths.root, - path.resolve(import.meta.dirname, ".."), - ); - }); - }); -}); diff --git a/packages/plugin/test/fixture-projects/base-project/hardhat.config.ts b/packages/plugin/test/fixture-projects/base-project/hardhat.config.ts index 6d2d235..f1742e6 100644 --- a/packages/plugin/test/fixture-projects/base-project/hardhat.config.ts +++ b/packages/plugin/test/fixture-projects/base-project/hardhat.config.ts @@ -3,6 +3,34 @@ import MyPlugin from "../../../src/index.js"; const config: HardhatUserConfig = { plugins: [MyPlugin], + networks: { + withMyAccountIndex: { + type: "edr-simulated", + myAccountIndex: 1, + }, + withoutMyAccountIndex: { + type: "edr-simulated", + }, + withCustomAccounts: { + type: "edr-simulated", + accounts: [ + { + privateKey: + "0x1234567890123456789012345678901234567890123456789012345678901234", + balance: "1000000000000000000", + }, + ], + myAccountIndex: 0, + }, + withMyAccountIndexTooHigh: { + type: "edr-simulated", + myAccountIndex: 100000, + }, + withoutAccounts: { + type: "edr-simulated", + accounts: [], + }, + }, }; export default config; diff --git a/packages/plugin/test/myAccount.ts b/packages/plugin/test/myAccount.ts new file mode 100644 index 0000000..4f13fd4 --- /dev/null +++ b/packages/plugin/test/myAccount.ts @@ -0,0 +1,55 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { NetworkConnection } from "hardhat/types/network"; +import { HardhatPluginError } from "hardhat/plugins"; +import { createFixtureProjectHRE } from "./helpers/fixture-projects.js"; + +async function assertMyAccount( + networkConnection: NetworkConnection, + accountIndex: number, +) { + const accounts: string[] = await networkConnection.provider.request({ + method: "eth_accounts", + }); + + assert.equal(networkConnection.myAccount, accounts[accountIndex]); +} + +describe("myAccount initialization on network connection", () => { + it("should initialize the myAccount field on the network connection", async () => { + const hre = await createFixtureProjectHRE("base-project"); + + await assertMyAccount(await hre.network.connect("withMyAccountIndex"), 1); + await assertMyAccount( + await hre.network.connect("withoutMyAccountIndex"), + 0, + ); + }); + + it("should take into account the `accounts` field", async () => { + const hre = await createFixtureProjectHRE("base-project"); + + await assertMyAccount(await hre.network.connect("withCustomAccounts"), 0); + }); + + it("should throw a plugin error if the myAccountIndex is too high with respect to the accounts", async () => { + const hre = await createFixtureProjectHRE("base-project"); + + await assert.rejects( + async () => { + await hre.network.connect("withMyAccountIndexTooHigh"); + }, + HardhatPluginError, + "hardhat-plugin-template: Invalid index 100000 for myAccount when connecting to network withMyAccountIndexTooHigh", + ); + + await assert.rejects( + async () => { + await hre.network.connect("withoutAccounts"); + }, + HardhatPluginError, + "hardhat-plugin-template: Invalid index 0 for myAccount when connecting to network withoutAccounts", + ); + }); +});