Reputation: 34830
I have a local hardhat node spawned simply as:
npx hardhat node
I have a smart contract code that I want to test that mints 100 NFTs with a simple loop. The code basically adds the address to a map and calls OpenZeppelin's _safeMint
function in a pretty standard manner.
However, in my tests it takes a few seconds to run this function, which is a bit too much for 100-iteration for loop. I've tried enabling/disabling autominting from Hardhat config but doesn't seem to change anything.
I will need to run this function for many iterations (10000) in my tests, so the duration of the call is unacceptable. I'm also on M1 Max so I doubt it's my CPU that's the bottleneck for a 100-iteration for loop which should probably take a few nanoseconds.
How can I make hardhat execute contract code faster?
(Solidity ^0.8.0, hardhat 2.8.2)
Upvotes: 5
Views: 3028
Reputation: 653
I'm going to try to answer your question in a generalised manner, however listing the methods in descending order of relevance to your requirement. Here's a guide on how to speed up your Hardhat test runs:
The default local node provided by Hardhat uses the ethereumjs EVM, which can be relatively slow. Consider switching to a faster local node for improved performance.
Anvil is a local Ethereum node designed for development. Notably, it uses the revm, an EVM implemented in Rust, which is known for its performance. See ziyadedher/evm-bench for a performance comparison of different EVM implementations.
To integrate Anvil with Hardhat, there's a plugin named hardhat-anvil. It's recommended to set launch: false
and spawn anvil
manually in a separate shell which you can do using the following command:
anvil --prune-history --order fifo --code-size-limit 4294967296 -m "test test test test test test test test test test test junk" --gas-limit 100000000000
For a complete list of all anvil
options see: https://book.getfoundry.sh/reference/anvil/#options
Additionally, you can save some time by disabling transaction signature verification using the eth_sendUnsignedTransaction
method that accepts the same parameters as eth_call
:
await network.provider.send("eth_sendUnsignedTransaction", [{
data,
from,
to,
gas,
gasPrice
}]);
Hardhat supports Mocha's parallel test execution. Running tests in parallel can considerably reduce the overall execution time, especially if you have a multi-core CPU.
Steps:
--parallel
flag:
npx hardhat test --parallel
parallel: true
in the Mocha section of your Hardhat config.Mocha's parallel execution runs tests in separate files concurrently. Therefore, to maximize this feature, if you have multiple test cases(e.g. for a kind of property-based test) for a given test function you can distribute/partition these test cases across multiple files.
Steps:
Create multiple duplicate test files, for example, named test-foo-{i}.spec.ts
where {i}
is the file number.
Partition your test cases equally among these files. To automate this:
import { getTestCases } from './getTestCases';
const testCases = getTestCases(); // if you have 70 test cases then these test cases will be split 10 per file
const totalFiles = 7; // if mocha is able to process 7 files concurrently then you can create 7 duplicates of the same file
function getFileIndex(): number {
const fileName = __filename.split('-').pop()?.split('.')[0] ?? '1';
return parseInt(fileName, 10);
}
const totalCases = testCases.length;
const casesPerFile = Math.ceil(totalCases / totalFiles);
const startIndex = (getFileIndex() - 1) * casesPerFile;
const endIndex = Math.min(startIndex + casesPerFile, totalCases);
const fileTestCases = testCases.slice(startIndex, endIndex); // the test cases for this file to process
// Now run the tests using these cases
Ensure that your test cases are optimized:
NB: If you're using a local node other than the hardhat local node(e.g. anvil
) then when using hardhat --parallel
you must ensure that each file is using a separate set of accounts to avoid nonce collisions. This isn't an issue when using hardhat --parallel
with hardhat being the configured network as hardhat likely spawns multiple hardhat nodes for each test script or maybe doesn't check the transaction nonces.
Instead of implementing complex test logic in TypeScript, consider using helper Solidity contracts to encapsulate and execute that logic.
a. Create Helper Contract: Write Solidity test contract/s that mimics the operations you want to test. For example, if you're testing token transfers, the test contract could manage multiple transfers in one function.
b. Interact with Helper: In your TypeScript tests, deploy and interact with this helper contract instead of sending multiple individual transactions.
c. Batch Operations: Use the helper contract to batch multiple operations into single transactions.
gasLimit
and gasPrice
:By manually specifying these values, you can avoid the overhead of eth_estimateGas
and eth_gasPrice
calls. To avoid transactions failing due to a too low override be careful to set these values sufficiently high.
await contract.functionName(args, {
gasLimit: 10_000_000,
gasPrice: ethers.utils.parseUnits("1", "gwei")
});
You could also consider setting this globally in the hardhat network config using the blockGasLimit
, gas
and gasPrice
fields.
Snapshotting and restoring allow you to capture the entire state of the blockchain at a given point and revert to that state at will.
const snapshotId = await ethers.provider.send("evm_snapshot", []);
// ... tests or operations ...
await ethers.provider.send("evm_revert", [snapshotId]);
Use loadFixture to automate the process of taking and restoring a snapshot.
The way your TypeScript tests are written can impact the test execution time. Here are some optimizations:
If the order of transactions isn't critical, send them simultaneously. You can also do this for eth_call
requests when calling view
functions or staticcalls to stateful functions.
await Promise.all([
contractA.functionA(args),
contractB.functionB(args),
// ...
]);
If you're repeatedly fetching the same data from the blockchain, consider caching it in a variable.
const tokenName = await tokenContract.name();
Reduce unnecessary queries to the chain. If possible, calculate or infer results without additional chain queries.
If you use a CI/CD system like GitHub Actions, ensure:
Upvotes: 4
Reputation: 2905
The solution below is a hack, but I've used it extensively in my tests with for loops up to 5000 iterations and it can run just fine (takes only a couple minutes instead of hours when using automining). The gist of it is to disable automining and interval mining and instead manually mine the blocks on demand.
// enable manual mining
await network.provider.send("evm_setAutomine", [false]);
await network.provider.send("evm_setIntervalMining", [0]);
// create the transactions, which will be pending
await Promise.all(promises); // promises consists of all the txs
// mine the needed blocks, below we mine 256 blocks at once (how many blocks to
// mine depends on how many pending transactions you have), instead of having
// to call `evm_mine` for every single block which is time consuming
await network.provider.send("hardhat_mine", ["0x100"]);
// re-enable automining when you are done, so you dont need to manually mine future blocks
await network.provider.send("evm_setAutomine", [true]);
Upvotes: 5