How to make local hardhat network run faster
Asked Answered
M

2

5

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)

Monogamy answered 4/6, 2022 at 6:27 Comment(5)
Hardhat node is built on top of the JavaScript VM emulator, which I'm guessing might be the performance bottleneck since it's an interpreted language. I don't know the solution to your question but it's worth trying a compiled emulator, e.g. Ganache.Miniskirt
@PetrHejda I've installed and tried Ganache. Unfortunately it's even slower than default hardhat network. All the tests take a few times more time.Soulier
paste your code, remember that in the end, your code will be compiled to bytecode that will be ran by the same hardhat local node, so it might be your code. I've been working with hardhat for the past 2 years and I didn't had any issues regarding performance.Cluck
@Cluck I can't paste the code due to privacy reasons. but I can safely say that the mint function consists of a few requires, mapping assignments mapping[tokenId] = value, and then just calling OpenZeppelin's standard ERC721 _safeMint internally. if I call this 100 times (which should be literally nothing computationally) it takes a few seconds to run. the length is directly proportional to how many times _safeMint is called.Soulier
Use foundry for tests instead of Hardhat, you will need to rewrite your test in solidity. github.com/foundry-rs/foundry Founry will run those tests in under a second.Ebro
S
5

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]);
Sexed answered 22/9, 2022 at 7:49 Comment(3)
I really like this answer!! I've a question: do you think its a good idea to put a network.provider.send("hardhat_mine", ["0x100"]) every X number of transactions within a for loop?I guess that would be the best if you want to iterate many timesLedbetter
@Ledbetter I don't think there's any problem with that, however you should consider how many blocks you need to mine at once - which depends on the block gas limit of the network as well as the average gas cost of your txns. You dont need to mine 256 blocks (0x100) every time :)Sexed
yeah I was thinking of increasing the gas limit but I choose to keep it as similar as the real network; Actually its just how i was testing, every 10~15 transactions in the loop I mine 1~2 blocks just to not get "timeouts" :3 but I wanted feedback <3 thanks you <3Ledbetter
P
3

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:

1. Use a Faster Local Ethereum Node

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: A High-Performance Local Node

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
}]);

2. Leverage Parallel Test Execution

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:

  • Use the --parallel flag:
    npx hardhat test --parallel
    
  • Alternatively, set 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:

  • Avoid redundancy: Ensure there's no overlapping or duplicated test logic.
  • Mock external calls where possible to reduce external dependencies and network calls. Don't use a hardhat network fork unnecessarily.

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.

3. Offloading Test Logic to Solidity:

Instead of implementing complex test logic in TypeScript, consider using helper Solidity contracts to encapsulate and execute that logic.

Steps:

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.

Benefits:

  • Efficiency: Solidity can often perform batch operations faster than multiple individual transactions in TypeScript.
  • Simplicity: TypeScript tests become cleaner, focusing on high-level operations.

4. Specifying 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.

Usage:

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.

5. Snapshotting & Restoring (and Using Fixtures):

Snapshotting and restoring allow you to capture the entire state of the blockchain at a given point and revert to that state at will.

Usage:

  • Before a set of tests that require a certain setup (e.g., deploying specific contracts and initializing states), take a snapshot.
  • After running the tests, revert to the snapshot. This avoids the need to redo the setup for each subsequent test.

How to Implement:

const snapshotId = await ethers.provider.send("evm_snapshot", []);
// ... tests or operations ...
await ethers.provider.send("evm_revert", [snapshotId]);

Benefits:

  • Efficiency: Avoids redundant deployments and state initializations.
  • Clean Environment: Ensures each test starts with a consistent environment, minimizing side effects.

Automate Snapshots with Fixtures:

Use loadFixture to automate the process of taking and restoring a snapshot.

6. Optimize TypeScript Code:

The way your TypeScript tests are written can impact the test execution time. Here are some optimizations:

a. Batch Transactions:

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),
  // ...
]);

b. Cache Results:

If you're repeatedly fetching the same data from the blockchain, consider caching it in a variable.

const tokenName = await tokenContract.name();

c. Limit Chain Queries:

Reduce unnecessary queries to the chain. If possible, calculate or infer results without additional chain queries.

7. Continuous Integration (CI) Improvements

If you use a CI/CD system like GitHub Actions, ensure:

  • Your CI environment is optimized for speed.
  • Use caching for dependencies to reduce installation times.
  • Parallelize CI tasks where possible.
Proper answered 25/9, 2023 at 9:51 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.