A hardhat plugin offering the possibility to generate new contract files (.sol) out of an extensible list of contract blueprints.
Run this command to install it from NPM:
npm install --save-dev hardhat-common-tools@^1.3.0 hardhat-enquirer-plus@^1.4.0 hardhat-blueprints@^1.2.3
This is a hardhat plugin, so the first thing to do is to install it in your hardhat.config.ts file:
require("hardhat-common-tools");
require("hardhat-enquirer-plus");
require("hardhat-blueprints");
Once there, you can make use of it (this supports both viem-enabled and ethers-enabled projects):
Write this command just to start:
npx hardhat blueprint list
At first (if you did not register any new blueprint nor installed other packages that do it) you will see this content:
These are the available blueprints you can use in the `apply` command:
- contract: An empty contract
- Arguments:
- SOLIDITY_VERSION: The Solidity version for the new file (An X.Y.Z Solidity version in the project)
- interface: An empty interface
- Arguments:
- SOLIDITY_VERSION: The Solidity version for the new file (An X.Y.Z Solidity version in the project)
- library: An empty library
- Arguments:
- SOLIDITY_VERSION: The Solidity version for the new file (An X.Y.Z Solidity version in the project)
- existing-contract-deployment-module: An ignition module for an existing contract (by artifact ID and contract address)
- Arguments:
- CONTRACT_NAME: The type to use for the contract (The ID of an artifact)
- CONTRACT_ADDRESS: The address where the contract is deployed (A checksum address)
- new-contract-deployment-module: An ignition module for a new contract (by artifact ID)
- Arguments:
- CONTRACT_NAME: The type to use for the contract (The ID of an artifact)
Which details many entries (there are 5 built-in entries by default), like "contract" or "interface" and 3 more.
If you expect a blueprint to be available for use, either provided by your own code or a different plugin, ensure that it is listed here (otherwise, it won't be available).
There are certain types of blueprints here to account for:
- Source code blueprints. They typically come from .sol files and will end in your sources
directory (e.g. your project's
contracts/
directory). - Ignition modules. This will be useful if your project uses
hardhat-ignition
. They are .js files and will end in yourignition/modules/
directory.
But, independently of the type, applying a blueprint has always this workflow:
- You tell which blueprint to use.
- You tell the name of the result file (be it a module or a contract / interface / library).
- You tell whatever arguments are required (and validated).
This can be done in two ways (which can be partially applied):
- You add a positional argument with a valid blueprint key to use (e.g. "contract") or it will be prompted for you to manually fill.
- You tell all the required arguments, one by one. For example, SOLIDITY_VERSION=0.8.24
(the format is: like environment variables). These are positional arguments. If you don't
specify one or more of the required arguments (and with a valid value for it), it/they'll
be prompted for you to manually fill them.
- This includes the
SCRIPT_NAME
argument (e.g. SCRIPT_NAME=MySampleContract for the "contract" example).
- This includes the
- The file will be immediately generated.
The parameter system is based on enquirer
but, in this case, it has an upper level of
abstraction (which is, for example, useful for describing the metadata in the list
task).
So these are actually not prompts but instead presets or argument types, which are still built on top of prompts. The available argument types are strings:
-
typeName
: A PascalCase name, valid for contract/interface/library names. -
identifier
: A camelCase name, valid for variable or function names. -
contract
: A reference to a contract (from compiled artifacts only). -
numeric-string
: A base-10 number. Returns a string. -
integer-string
: A base-10, or 0x-prefixed base-16, number. Returns a string. -
bigint
: A base-10, or 0x-prefixed base-16, number. Returns a bigint. -
boolean
: A boolean value. You'll usetrue
orfalse
there. -
account
: An account index (works both in viem and ethers). -
address
: A checksum-valid address (works both in viem and ethers). -
smart-address
: A checksum-valid address or an account index (works both in viem and ethers). -
solidity
A solidity version, in format X.Y.Z. It must be one of the versions installed in your project. -
token-amount
: A token amount with units, e.g.: "1.5ether", "2 ether", "1gwei", "0.5 gwei" or other standard units. -
int8
toint256
: A BigInt input of that size. -
uint8
touint256
: A BigInt input of that size. -
bytes1
tobytes32
: An hexadecimal input of that (byte-wise) length. -
string
andbytes
: An arbitrary string or arbitrary byte-aligned hex string. - Alternatively, an object. The format of this object is the same as the entries used in the
prompt
method in theenquirer
library (but also considering the available types from thehardhat-enquirer-plus
package). -
hashed-text
: A text that will be hashed with keccak256. The result is hashed with keccak256, compatible with Solidity'skeccak256(abi.encodePacked(x))
. -
hashed
: Either a text that will be hashed or an already-computed valid hash (as inbytes32
format). Thegiven
value may be a string (which will be hashed), a compound object like{hashed: false, value: string}
(whosevalue
will be hashed) or a compound object like{hashed: true, value: string}
(whosevalue
must be a validbytes32
value and will not be hashed but returned as-is).
To create an AAA-9999 argument type, not listed among these (and not wishing to use the custom object as a one-off type), you can call:
// Registering a new "plate-code" type:
hre.blueprints.registerBlueprintArgumentType(
"plate-code", {
type: "plus:given-or-valid-input",
validate: /^[A-Z]{3}-[0-9]{9}$/,
makeInvalidInputMessage: (v) => `Invalid plate code: ${v}`,
onInvalidGiven: (v) => console.error(`Invalid given plate code: ${v}`)
}, "An AAA-9999 code (3 uppercase letters, 4 decimal digits)"
);
It will work as expected when you try to define arguments of "plate-code"
type for
your new blueprints.
You'll only typically need this when developing your own plugin (on top of this one) which needs to also define new types for some blueprints on its own.
Considering the given types (and new ones) you could manually implement reading arguments by invoking this method, for example:
const prompts = hre.blueprints.prepareArgumentPrompts([
{
name: "fromAddress",
description: "The source address",
message: "Enter the source address",
argumentType: "smart-address"
},
{
name: "toAddress",
description: "The destination address",
message: "Enter the destination address",
argumentType: "smart-address"
},
{
name: "id",
description: "The token id",
message: "Enter the ID of the token",
argumentType: "bigint"
},
{
name: "value",
description: "The token id",
message: "Enter the ID of the token",
argumentType: "bigint"
},
{
name: "plate-code",
description: "A plate code",
message: "A plate code (it will be hashed and used as data)",
argumentType: "plate-code"
}
]);
console.log(await new hre.enquirerPlus.Enquirer().prompt(prompts));
In the end, the result
will be a literal object with fields id
, value
, data
, fromAddress
, toAddress
.
Using arrays and tuples is done through custom, non-registered, prompts. However, this package offers some helpers to aid for that purpose. For example, to ask for an array of addresses and then a tuple type of (uint8, int16), this would do the full job:
const prompts = hre.blueprints.prepareArgumentPrompts([
hre.blueprints.arrayArgument({
message: "Input an array of addresses",
description: "An addresses array",
name: "addresses",
elements: {
argumentType: "smart-address",
message: "Element ${index}"
}
}),
hre.blueprints.tupleArgument({
message: "Input a tuple",
description: "A tuple",
name: "compound",
elements: [{
name: "foo",
argumentType: "uint8"
}, {
name: "bar",
argumentType: "int16"
}]
}),
]);
await hre.enquirerPlus.Enquirer.prompt(prompts);
// It'd return something like: {
// addresses: [
// '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266',
// '0x70997970C51812dc3A010C7d01b50e0d17dc79C8'
// ],
// compound: [ 255n, 32767n ]
// }
In order to provide some given values and force the non-interactive mode, this would also work (considering that the non-interactive mode is enabled for any type, not just tuples or arrays):
const prompts = hre.blueprints.prepareArgumentPrompts([
hre.blueprints.arrayArgument({
message: "Input an array of addresses",
description: "An addresses array",
name: "addresses",
elements: {
argumentType: "smart-address",
message: "Element ${index}"
}
}),
hre.blueprints.tupleArgument({
message: "Input a tuple",
description: "A tuple",
name: "compound",
elements: [{
name: "foo",
argumentType: "uint8"
}, {
name: "bar",
argumentType: "int16"
}]
}),
], true, {"addresses": ["0", "1"], "compound": ["0xff", "0x7fff"]});
Where the third parameter is a given value matching both names.
Also, arrays of tuples are supported (and nesting more stuff would be done in an analogous way):
const prompts = hre.blueprints.prepareArgumentPrompts([
hre.blueprints.arrayArgument({
message: "Input an array of addresses",
description: "An addresses array",
name: "compounds",
elements: hre.blueprints.tupleArgument({
message: "Input the ${index}",
description: "A tuple",
name: "compound",
elements: [{
name: "foo",
argumentType: "uint8"
}, {
name: "bar",
argumentType: "int16"
}]
})
}),
]);
await hre.enquirerPlus.Enquirer.prompt(prompts);
Which also supports given values and non-interactive mode:
const prompts = hre.blueprints.prepareArgumentPrompts([
hre.blueprints.arrayArgument({
message: "Input an array of addresses",
description: "An addresses array",
name: "compounds",
elements: hre.blueprints.tupleArgument({
message: "Input the ${index}",
description: "A tuple",
name: "compound",
elements: [{
name: "foo",
argumentType: "uint8"
}, {
name: "bar",
argumentType: "int16"
}]
})
}),
], true, {"compounds": [["0xff", "0x7fff"], ["0xff", "0x7fff"]]});
await hre.enquirerPlus.Enquirer.prompt(prompts);
To create a new blueprint, not listed among the default blueprints described here, you can call:
// Let's define two new argument types as well.
hre.blueprints.registerBlueprintArgumentType(
"erc20-symbol", {
type: "plus:given-or-valid-input",
validate: /^[A-Z][A-Z]{2,}$/,
makeInvalidInputMessage: (v) => `Invalid ERC20 symbol: ${v}`,
onInvalidGiven: (v) => console.error(`Invalid given ERC20 symbol: ${v}`)
}, "An uppercase (letter-starting) short ERC20 symbol name"
);
hre.blueprints.registerBlueprintArgumentType(
"erc20-token-name", {
type: "plus:given-or-valid-input",
validate: /^[ A-Za-z0-9_-]+$/,
makeInvalidInputMessage: (v) => `Invalid ERC20 token name: ${v}`,
onInvalidGiven: (v) => console.error(`Invalid given ERC20 token name: ${v}`)
}, "An ERC20 token title/name"
);
// Let's say it is an OpenZeppelin-based ERC20 contract.
const filePath = path.resolve(__dirname, "path", "to", "my", "ERC20.sol.template");
hre.blueprints.registerBlueprint(
"erc-20", "MyERC20", "An OpenZeppelin-based ERC20 contract",
filePath, "solidity", [{
// You'll typically define this argument for .sol files.
name: "SOLIDITY_VERSION",
description: "The Solidity version for the new file",
message: "Choose the solidity version for this file",
argumentType: "solidity"
}, {
name: "SYMBOL",
description: "The symbol for this token",
message: "What's the symbol for your token?",
argumentType: "erc20-symbol"
}, {
name: "TOKEN_NAME",
description: "The name for this token",
message: "Give a name/title to your token",
argumentType: "erc20-token-name"
}]
);
PLEASE NOTE: You must not define the SCRIPT_NAME
argument. It is already defined.
The ERC20.sol.template
contents will look like this:
// SPDX-License-Identifier: UNLICENSED
pragma solidity #SOLIDITY_VERSION#;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract #SCRIPT_NAME# is ERC20 {
constructor() ERC20("#TOKEN_NAME#", "#SYMBOL#") {}
}
Notice how both SCRIPT_NAME
and the oter 3 arguments are defined. They'll be properly
accounted for when trying the command:
npx hardhat blueprint apply erc20 ... # the arguments here
You have two options here:
-
Invoke the
apply
scoped task (running children tasks is documented in hardhat's official docs), properly specifying the arguments. -
Invoke
hre.blueprints.applyBlueprint
. For example, to apply thecontract
blueprint:# 1. nonInteractive is being set to false, thus allowing prompts # if something were to be invalid. Pass it as true in your # calls if you want to ensure that no interactions must occur # via prompting (raising an error instead). # 2. Provided 0.8.24 is a valid solidity version in your project. # As SCRIPT_NAME is given, also any other expected argument # can also be given. await hre.blueprints.applyBlueprint("contract", false, {"SCRIPT_NAME": "MyContract", "SOLIDITY_VERSION": "0.8.24"});
There are two extra enquirer-plus types registered here:
- Registered as "plus:hardhat:given-or-valid-hashed-input" and used in the "hashed-text" argument type,
its class is
hre.enquirerPlus.Enquirer.HashedInput
and takes extra options:given
(a string) andnonInteractive
. - Registered as "plus:hardhat:given-or-valid-smart-hashed-input" and used in the "hashed" argument type,
its class is
hre.enquirerPlus.Enquirer.SmartHashedInput
and takes extra options:given
(a string, a{hashed: false, value: string}
or a{hashed: true, value: string}
) andnonInteractive
.
They can be safely used in calls to hre.enquirerPlus.Enquirer.prompt
.