Skip to main content

Hardhat: Debugging smart contracts

In this tutorial, you'll learn how to debug your smart contracts using the built-in debugging capabilities of Hardhat.


Objectives

By the end of this tutorial, you should be able to:

  • Use console.log to write debugging logs
  • List common errors and their resolutions
  • Determine if an error is a contract error or an error in the test

Overview

Debugging smart contracts can be a challenging task, especially when dealing with decentralized applications and blockchain technology. Hardhat provides powerful tools to simplify the debugging process.

In this tutorial, you will explore the essential debugging features offered by Hardhat and learn how to effectively identify and resolve common errors in your smart contracts.

Your first console.log

One of the key features of Hardhat is the ability to use console.log for writing debugging logs in your smart contracts. In order to use it, you must include hardhat/console.sol in the contract you wish to debug.

In the following contract Lock.sol for example, you include hardhat/console.sol by importing it and adding a few console.logs in the constructor with the text "Creating" and the Ether balance of the contract. This can help you not only with tracking that the contract was created successfully but also, more importantly, with the ability to include additional logs such as the balance of the contract after it was created:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.19;

import "hardhat/console.sol";

contract Lock {
uint256 public unlockTime;
address payable public owner;

constructor(uint _unlockTime) payable {
require(
block.timestamp < _unlockTime,
"Unlock time should be in the future"
);

unlockTime = _unlockTime;
owner = payable(msg.sender);

console.log("Creating");
console.log("Balance", address(this).balance);
}
}

In order to test it, you need to create a new file in the test folder called Lock.test.ts with the following content:

import { expect } from "chai";
import { ethers } from "hardhat";

import { time } from "@nomicfoundation/hardhat-network-helpers";
import { SignerWithAddress } from '@nomicfoundation/hardhat-ethers/signers'

import { Lock__factory, Lock } from '../typechain-types'

describe("Lock Tests", function () {
const UNLOCK_TIME = 10000;
const VALUE_LOCKED = ethers.parseEther("0.01");

let lastBlockTimeStamp: number;
let lockInstance: Lock;
let ownerSigner: SignerWithAddress

before(async () => {
lastBlockTimeStamp = await time.latest()

const signers = await ethers.getSigners()
ownerSigner = signers[0]

lockInstance = await new Lock__factory().connect(ownerSigner).deploy(lastBlockTimeStamp + UNLOCK_TIME, {
value: VALUE_LOCKED
})
})

it('should get the unlockTime value', async () => {
expect(await lockInstance.unlockTime()).to.equal(lastBlockTimeStamp + UNLOCK_TIME)
})
});

Notice that a single test is included in order to get proper logs. However, you're only interested in the creation process that happens in the before hook. Then, you can run:

npx hardhat test

You should see the following in the terminal:

  Lock
Creating
Balance 10000000000000000
✔ should get the unlockTime value

The terminal shows the text "Creating" and the balance (which is 0.01 Ether) because during the creation, you are depositing Ether in the smart contract via the value property.

A note about console.log

In the previous example, you used console.log to include some debugging logs. Be aware that the console.log version of Solidity is limited compared to the ones that are provided in other programming languages, where you can log almost anything.

Console.log can be called with up to four parameters of the following types:

  • uint
  • string
  • bool
  • address

Hardhat includes other console functions, such as:

  • console.logInt(int i)
  • console.logBytes(bytes memory b)
  • console.logBytes1(bytes1 b)
  • console.logBytes2(bytes2 b)
  • ...
  • console.logBytes32(bytes32 b)

These log functions are handy when the type you intend to log doesn't fall within the default accepted types of console.log. For further details, refer to the official console.log documentation.

Identifying common errors

While debugging your smart contracts, it's crucial to be familiar with common errors that can arise during development. Recognizing these errors and knowing how to resolve them is an important skill.

In our Base Learn series of tutorials, we cover a few compile-time errors in Error Triage. Other errors, such as reverts or index out of bounds errors can be unexpected during the runtime of the smart contract.

The following explores typical techniques to debug these types of errors.

Revert errors

When a transaction fails due to a require or revert statement, you'll need to diagnose why the condition isn't met and then resolve it. Typically, this involves verifying input parameters, state variables, or contract conditions.

The following is the Lock.sol contract with a require statement that validates that the parameter you are passing (_unlockTime) must be greater than the current block.timestamp.

A simple solution to troubleshoot this error is to log the value of block.timestamp and _unlockTime, which will help you compare these values and then ensure that you are passing the correct ones:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.19;

import "hardhat/console.sol";

contract Lock {
uint public unlockTime;
address payable public owner;

// event Withdrawal(uint amount, uint when);

constructor(uint _unlockTime) payable {
console.log("_unlockTime",_unlockTime);
console.log("block.timestamp",block.timestamp);
require(
block.timestamp < _unlockTime,
"Unlock time should be in the future"
);

unlockTime = _unlockTime;
owner = payable(msg.sender);

console.log("Creating");
console.log("Balance", address(this).balance);
}
}

When you run the tests with npx hardhat test, you'll then see the following:

Lock Tests
_unlockTime 1697493891
block.timestamp 1697483892
Creating
Balance 10000000000000000
✔ should get the unlockTime value

You are now able to see the block.timestamp and the value you are passing, which makes it easier to detect the error.

Unintended behavior errors

Unintended behavior errors occur when you introduce unexpected behavior into the codebase due to a misunderstanding in the way Solidity works.

In the following example, LockCreator is a contract that allows anybody to deploy a Lock.sol instance. However, the LockCreator contains an error: the createLock functions are able to accept Ether to be locked but the amount sent is not being transferred to the Lock contract:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.19;

import "hardhat/console.sol";

import {Lock} from "./Lock.sol";

contract LockCreator {

Lock[] internal locks;

// Example of bad code, do not use
function createLock(uint256 _unlockTime) external payable {
Lock newLock = new Lock(_unlockTime);
locks.push(newLock);
}
}

You can create a test file LockCreator.test.ts that can identify the error and then solve it:

import { ethers } from "hardhat";

import { time } from "@nomicfoundation/hardhat-network-helpers";
import { SignerWithAddress } from '@nomicfoundation/hardhat-ethers/signers'

import { LockCreator, LockCreator__factory } from '../typechain-types'

describe("LockCreator Tests", function () {
const UNLOCK_TIME = 10000;
const VALUE_LOCKED = ethers.parseEther("0.01");

let lastBlockTimeStamp: number;
let lockInstance: LockCreator;
let ownerSigner: SignerWithAddress

before(async () => {
const signers = await ethers.getSigners()
ownerSigner = signers[0]

lockInstance = await new LockCreator__factory().connect(ownerSigner).deploy()
})

it('should create a lock', async () => {
lastBlockTimeStamp = await time.latest()
await lockInstance.createLock(lastBlockTimeStamp + UNLOCK_TIME, {
value: VALUE_LOCKED
})
})
});

The following appears in the terminal where you can see the balance is 0:

  LockCreator Tests
Creating
Balance 0
✔ should create a lock (318ms)

Although this issue can be avoided by adding more test cases with proper assertions, the re-transfer of Ether from the LockCreator was something you may have overlooked.

The solution is to modify the createLock function with:

function createLock(uint256 _unlockTime) external payable {
Lock newLock = new Lock{ value: msg.value}(_unlockTime);
locks.push(newLock);
}

Out-of-bounds errors

Attempting to access arrays at an invalid position can also cause errors.

If you wish to retrieve all the Lock contract instances being created in the previous example, you can make the locks array public. In order to illustrate this example, though, you can create a custom function called getAllLocks:

contract LockCreator {
//
// rest of the code..
//
function getAllLocks() external view returns(Lock[] memory result) {
result = new Lock[](locks.length);
for(uint i = 0; i <= locks.length; i++){
result[i] = locks[i];
}
}
}

The function can be tested with the following test:

describe("LockCreator Tests", function () {
const UNLOCK_TIME = 10000;
const VALUE_LOCKED = ethers.parseEther("0.01");

let lastBlockTimeStamp: number;
let lockInstance: LockCreator;
let ownerSigner: SignerWithAddress

before(async () => {
const signers = await ethers.getSigners()
ownerSigner = signers[0]

lockInstance = await new LockCreator__factory().connect(ownerSigner).deploy()

lastBlockTimeStamp = await time.latest()

await lockInstance.createLock(lastBlockTimeStamp + UNLOCK_TIME, {
value: VALUE_LOCKED
})
})

it('should get all locks', async () => {
const allLocks = await lockInstance.getAllLocks()

console.log("all locks", allLocks)
})
});

Which will then throw an error:

LockCreator Tests
Creating
Balance 10000000000000000
1) should get all locks

0 passing (3s)
1 failing

1) LockCreator Tests
should get all locks:
Error: VM Exception while processing transaction: reverted with panic code 0x32 (Array accessed at an out-of-bounds or negative index)

You can include some debugging logs to identify the issue:

 function getAllLocks() external view returns(Lock[] memory result) {
result = new Lock[](locks.length);

console.log("locks length %s", locks.length);

for(uint i = 0; i <= locks.length; i++){
console.log("Locks index %s", i);
result[i] = locks[i];
}
}

Then, you see the following in the terminal:

 LockCreator Tests
Creating
Balance 10000000000000000
locks length 1
Locks index 0
Locks index 1
1) LockCreator Tests
should get all locks:
Error: VM Exception while processing transaction: reverted with panic code 0x32 (Array accessed at an out-of-bounds or negative index)

Since arrays are 0 index based, an array with 1 item will store that item at the 0 index. In the above example, the if statement compares <= against the length of the array, so it tries to access the element in position 1, and crashes.

Here's the simple solution:

 function getAllLocks() external view returns(Lock[] memory result) {
result = new Lock[](locks.length);

console.log("locks length %s", locks.length);

for(uint i = 0; i < locks.length; i++){
console.log("Locks index %s", i);
result[i] = locks[i];
}
}

Which immediately solves the problem:

  LockCreator Tests
Creating
Balance 10000000000000000
locks length 1
Locks index 0
all locks Result(1) [ '0x83BA8C2028EE8a6476396145C7692fBD09337acD' ]
✔ should get all locks


1 passing (3s)

Conclusion

In this tutorial, you've learned some techniques about how to debug smart contracts using Hardhat. You explored some common cases of various errors and how by simply using console.log and a proper test, you can identify and solve the problem.


See also

We use cookies and similar technologies on our websites to enhance and tailor your experience, analyze our traffic, and for security and marketing. You can choose not to allow some type of cookies by clicking . For more information see our Cookie Policy.