How to set up a Hardhat project for Klaytn

How to set up a Hardhat project for Klaytn

Learn how to use and set up Hardhat, compile, test and deploy smart contracts to Klaytn considering it's fixed gas price model.

·

15 min read

Introduction

Building on Klaytn is pretty much the same as building on Ethereum!

You must have seen that in one of my previous articles.

This statement is valid because Klaytn is a Layer one EVM solution and it fully supports features for Ethereum Equivalence. Ethereum Equivalence I mean Ethereum tools can be used on Klaytn without any substantial modifications. These tools include Hardhat, Truffle, ethers js and web3 js.

Having said that, it is important to note that, unlike Ethereum, Klaytn makes use of a fixed gas price model. This means developers would need to be extra careful when using Ethereum native tooling for they have to set the right amount of gas when creating a transaction.

The need to put this into consideration when using Ethereum tools on Klaytn is the reason behind this article. Here I will explain how to set up a hardhat project, test and deploy it to klaytn putting into consideration the Klaytn compatible gas price using Hardhat and ethers js.

So let's get started! In this tutorial, we’ll go through the following:

  • What is Hardhat?
  • Installing Hardhat
  • Creating and configuring a new Hardhat project
  • Writing, testing, compiling and deploying smart contracts
  • Conclusion

Prerequisites

To follow this tutorial, you should have knowledge in:

Not familiar with the above? Learn the basics by clicking the links above, before proceeding with this tutorial on how to set up a hardhat project.

What is Hardhat

Hardhat is basically a development environment to compile, deploy, test and debug Ethereum smart contracts. Hardhat has a built-in Hardhat Network (like Ganache, your local blockchain) designed for development. It's also notable for solidity debugging featuring stack traces, console.log() and explicit error messages when transactions fail.

Installing Hardhat

To get started you need a working Node.js >= 12.0 version installed. You can skip this if you already have it installed.

Now let's install Hardhat using the npm CLI. Open a new terminal and run the below command

   mkdir klaytn_Hardhat_Example
   cd klaytn_Hardhat_Example
   npm init --yes
   npm install --save-dev hardhat

Creating and configuring a new Hardhat project

After successful installation, in the same directory where you installed hardhat, run

npx hardhat

hardhat.png

Your terminal would look like the above after running the command. For this guide please do select the Create an advanced sample project that uses TypeScript which looks like this after pressing enter

hardhat_ex.png

A. Configure package.json to install all dependencies

Navigate to package.json file, and paste the following dependencies for installation

   {
  "name": "hardhat-project",
  "devDependencies": {
    "@nomiclabs/hardhat-ethers": "^2.0.3",
    "@nomiclabs/hardhat-etherscan": "^2.1.8",
    "@nomiclabs/hardhat-waffle": "^2.0.1",
    "@typechain/ethers-v5": "^7.2.0",
    "@typechain/hardhat": "^2.3.1",
    "@types/chai": "^4.3.0",
    "@types/mocha": "^9.0.0",
    "@types/node": "^16.11.17",
    "@typescript-eslint/eslint-plugin": "^4.33.0",
    "@typescript-eslint/parser": "^4.33.0",
    "chai": "^4.3.4",
    "dotenv": "^10.0.0",
    "eslint": "^7.32.0",
    "eslint-config-prettier": "^8.3.0",
    "eslint-config-standard": "^16.0.3",
    "eslint-plugin-import": "^2.25.3",
    "eslint-plugin-node": "^11.1.0",
    "eslint-plugin-prettier": "^3.4.1",
    "eslint-plugin-promise": "^5.2.0",
    "ethereum-waffle": "^3.4.0",
    "ethers": "^5.5.2",
    "hardhat": "^2.9.1",
    "hardhat-gas-reporter": "^1.0.6",
    "prettier": "^2.5.1",
    "prettier-plugin-solidity": "^1.0.0-beta.13",
    "solhint": "^3.3.6",
    "solidity-coverage": "^0.7.17",
    "ts-node": "^10.4.0",
    "typechain": "^5.2.0",
    "typescript": "^4.5.4"
  },
  "dependencies": {
    "@openzeppelin/contracts": "^4.4.1",
    "@chainlink/contracts": "0.2.1",
    "mocha": "^9.2.1"
  }
}

After pasting this, run

npm install

Check out the full project source code on GitHub

B. configure the .env file

Navigate to the .env file. This file helps us load environment variables from a .env file into process.env. You would have to rename the file from its default to .env as seen below

.env.png

let's configure our .env file to look like this

 KLAYTN_URL='https://api.baobab.klaytn.net:8651'
 PRIVATE_KEY= your private key copied from kaikas wallet

In doing this, we would make some changes to our hardhat.config.ts file in later steps

C. Configure the hardhat.config.ts file

Navigate to the hardhat.config.ts file. This contains an object to be exported which by default has entries like solidity, networks, gasReporter, etherscan. We can see that below:

hardhat_config.png

Now lets configure our file to look like this considering the changes in our .env

    const config: HardhatUserConfig = {
  solidity: "0.8.4",
  networks: {
    klaytn: {
      url: process.env.KLAYTN_URL || "",
      gasPrice: 250000000000,
      accounts:
        process.env.PRIVATE_KEY !== undefined ? [process.env.PRIVATE_KEY] : [],
    },
  },
  gasReporter: {
    enabled: process.env.REPORT_GAS !== undefined,
    currency: "USD",
  },
  etherscan: {
    apiKey: process.env.ETHERSCAN_API_KEY,
  },
};

We would notice that I added a gasPrice property to the object. This is a result of the fixed gas price model I talked about previously. So this is how klaytn gas configuration is done when using hardhat to deploy or send transactions to the network. You can read more about it here

Some things to put in mind after configuring your file

  • Solidity

The solidity config is an optional field that is used to set the compiler version and some other things like optimizers et al.

  • Network configuration

The networks config field is an optional object where network names map to their configuration.

There are two kinds of networks in Hardhat: JSON-RPC based networks, which is what we will be using in this tutorial, and the built-in Hardhat Network. Note, If you omit this config, it uses "hardhat" as its default value.

Now that we are done with the configuration let's head to writing and deploying our actual smart contracts

Writing, testing, compiling and deploying smart contracts

In this tutorial, we’re going to create a simple smart contract that works like a bank. Users can deposit money and withdraw their funds.

i. Creating the smart contract

Navigate to your contracts folder and create a new file called KlayBank.sol

contracts/KlayBank.sol

Copy and paste the code below into your newly created solidity file.

   // SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;

contract KlayBank {
    mapping(address => uint) public balances;

    event depositEvent(uint _amount, address _depositor);
    event withdrawEvent(uint _amount, address _depositor, address _beneficiary);

    function deposit() public payable {
        balances[msg.sender] += msg.value;
        emit depositEvent(msg.value, msg.sender);
    }

    function withdraw(address _recipient, uint _amount) public {
        require(_recipient != address(0) ,"KlayBank: Cannot Send to Address Zero");
        require(_amount <= balances[msg.sender], "KlayBank: Insufficient Balance");

        balances[msg.sender] -= _amount;
        balances[_recipient] += _amount;

        emit withdrawEvent(_amount, msg.sender, _recipient);
    }

    function getBalance(address _addr) public view returns(uint) {
        return balances[_addr];
    }

}

Code walkthrough

In solidity code, the first line is usually the pragma. This declares the version of Solidity you wish to write your code in.

The next is the declaration of contract keyword, then the name of the contract. Here, our contract name is KlayBank.

Next, I declared a mapping of balances which maps an address to a unit i.e an EOA to its account balance which is populated when deposit or withdrawals takes place.

Next, I declared both the deposit and withdraw event to be emitted when the deposit and withdraw functions are been called.

Deposit Function

The deposit function is declared payable which means it can receive ethers. The line balances[msg.sender] += msg.value; assigns the money sent into the balance of the person calling the function (msg.sender). It finally emits the deposit event after the state change.

Withdraw Function

This takes in two parameters: the beneficiary address and the amount to be transferred. Moving forward we have some require statements that checks:

a. not sending to address zero

b. not sending the funds when the amount is greater than the user's available balance

The effect here is reducing the payer's balance and increasing the beneficiary's respectively. Lastly, the withdraw event is emitted after these state changes.

getBalance Function

This is a view function that returns the balance of the msg.sender

ii. Compiling the smart contract

To compile the contract, open up your terminal and run

npx hardhat compile

hardhar_comp.png

Our contract has been successfully compiled and it’s ready for use.

iii. Testing our contract

The absolute smartest thing you can do is write automated tests when building smart contracts. This will help guarantee your users' money is safe. In this tutorial, we would be making use of hardhat and mocha.

To test the contract, we need to get the contract address by deploying to hardhat localhost.

Navigate to the scripts folder and open the deploy.ts

scripts/deploy.ts

Copy and paste the following code in it

import { ethers } from "hardhat";

async function main() {
//   We get the contract to deploy
  const Bank = await ethers.getContractFactory("KlayBank");
  const bank = await Bank.deploy();
  await bank.deployed();
  console.log("Bank deployed to:", bank.address);
}
main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

Open up a new terminal to run Hardhat in-built Network by running

npx hardhat node

hardhat_node.png

Now that we have our hardhat node set up, let's deploy!

To deploy, run

npx hardhat run scripts/deploy.ts --network localhost

hardhat_dep.png

Now that we have our contract deployed, copy the contract address and paste it into your test file. Note we are still going to be interacting with our already deployed contract in later steps

Testing your contract

Navigate to your test folder and open up the index.ts file

test/index.ts

Copy and paste the code below into your test file

import { expect } from "chai";
import { ethers } from "hardhat";
import { KlayBank } from "../typechain";


let klayBank:KlayBank;


const deployedContract :string = "your deployed contract address";

describe("Get instance of KlayBank", function () {
  before(async function () {
    klayBank = await ethers.getContractAt("KlayBank", deployedContract) as KlayBank
  })
it("user balance should increase on deposit", async function () {
  const signer = await ethers.getSigners();
  const _depositor1 = signer[0].address
  const _depositorSigner = await ethers.getSigner(_depositor1);

  const balance: any = await klayBank.getBalance(_depositor1);

  const option = {value: ethers.utils.parseEther("1")}
  const deposit: any = await klayBank.connect(_depositorSigner).deposit(option);
  const tx = await deposit.wait();
  const value = tx.events[0].args[0]
  const depositor = tx.events[0].args[1];

  const balanceAfter: any = await klayBank.getBalance(_depositor1);

  expect(Number(balance.toString()) + Number(value.toString())).to.equal(Number(balanceAfter.toString())) 
  expect(_depositor1).to.equal(depositor) 
});

it("user and recipient balance should reduce and increase on withdraw respectively", async function () {
  const signer = await ethers.getSigners();
  const _depositor1 = signer[0].address
  const _depositorSigner = await ethers.getSigner(_depositor1);

  const balance: any = await klayBank.getBalance(_depositor1);
  const recipientBalB4: any = await klayBank.getBalance(signer[1].address);


  const withdraw: any = await klayBank.connect(_depositorSigner).withdraw(signer[1].address, "5000000000");
  const tx = await withdraw.wait();
  const value = tx.events[0].args[0]
  const depositor = tx.events[0].args[1];
  const recipient = tx.events[0].args[2];

  const balanceAfter: any = await klayBank.getBalance(_depositor1);
  const recBalanceAfter: any = await klayBank.getBalance(signer[1].address);

  expect(Number(balance.toString()) - Number(value.toString())).to.equal(Number(balanceAfter.toString())) 
  expect(Number(recipientBalB4.toString()) + Number(value.toString())).to.equal(Number(recBalanceAfter.toString())) 
  expect(_depositor1).to.equal(depositor);
  expect(signer[1].address).to.equal(recipient) 
});


it("Should revert when transferring to Address Zero", async function () {
  const signer = await ethers.getSigners();
  const _depositor1 = signer[0].address
  const _depositorSigner = await ethers.getSigner(_depositor1);

  await expect(klayBank.connect(_depositorSigner).withdraw(ethers.constants.AddressZero, "5000000000")).to.be.revertedWith("KlayBank: Cannot Send to Address Zero");
});

it("Should revert when transfer amount is greater than balance", async function () {
  const signer = await ethers.getSigners();
  const _depositor1 = signer[0].address
  const _depositorSigner = await ethers.getSigner(_depositor1);

  const balanceB4: any = await klayBank.getBalance(_depositor1);

  const balance: any = await klayBank.getBalance(_depositor1);
  await expect(klayBank.connect(_depositorSigner).withdraw(signer[1].address, balance.toString() + "1000000")).to.be.revertedWith("KlayBank: Insufficient Balance");
  expect(await klayBank.getBalance(_depositor1)).to.equal(balanceB4)
});

});

Test walkthrough

After successfully importing the needed packages like chai and ethers, let us take a quick look at the test code below:

  • describe : is a Mocha function that allows you to organize your tests. Having your tests organized makes debugging them easier. The describe function comprises of the name of a section of your test suite, and a callback. The callback must define the tests of that section. Mocha has four functions that let you hook into the test runner’s lifecycle. These are: before, beforeEach, after, afterEach.

They’re very useful to set up the environment for tests and to clean it up after they run. A common pattern is to declare some variables and assign them in the before and beforeEach callback.

  • beforeEach: will run before each test, re-deploying the contract every time. It receives a callback, which can be async.

  • it is another Mocha function. This is the one you use to define your tests, it receives the test name and a callback function.

  • Testing increase in user balance on deposit

   it("user balance should increase on deposit", async function () {
  const signer = await ethers.getSigners();
  const _depositor1 = signer[0].address
  const _depositorSigner = await ethers.getSigner(_depositor1);

  const balance: any = await klayBank.getBalance(_depositor1);

  const option = {value: ethers.utils.parseEther("1")}
  const deposit: any = await klayBank.connect(_depositorSigner).deposit(option);
  const tx = await deposit.wait();
  const value = tx.events[0].args[0];
  const depositor = tx.events[0].args[1];

  const balanceAfter: any = await klayBank.getBalance(_depositor1);

  expect(Number(balance.toString()) + Number(value.toString())).to.equal(Number(balanceAfter.toString())) 
  expect(_depositor1).to.equal(depositor) 
});

The line const signer = await ethers.getSigners() gets the provided hardhat signers

The line const balance: any = await klayBank.getBalance(_depositor1) gets the balance of the depositor before depositing.

The line const deposit: any = await klayBank.connect(_depositorSigner).deposit(option); calls the contract deposit function and deposits the 1 ether.

The line const balanceAfter: any = await klayBank.getBalance(_depositor1) gets the balance of the depositor after the deposit. It then expect the balance + amountDeposited(value) to be equal to balanceAfter.

  • Testing reversion on withdrawing to Address zero
  it("Should revert when transferring to Address Zero", async function () {
  const signer = await ethers.getSigners();
  const _depositor1 = signer[0].address
  const _depositorSigner = await ethers.getSigner(_depositor1);

  await expect(klayBank.connect(_depositorSigner).withdraw(ethers.constants.AddressZero, "5000000000")).to.be.revertedWith("KlayBank: Cannot Send to Address Zero");
});

The line await expect(klayBank.connect(_depositorSigner).withdraw(ethers.constants.AddressZero, "5000000000")).to.be.revertedWith("KlayBank: Cannot Send to Address Zero") calls the withdraw function by passing in address Zero which is supposed to revert. It expects it to revert with the error message declared in the contract code.

Running Test

Open up a new terminal and run

npx hardhat test test/index.ts --network localhost

hardhat_test.png

Yay!! our test passed 💃🎉

Deploying and Interacting with our Contract

Now that we have compiled and tested our contract, we can now deploy our contract to baobab

Navigate to contracts/deploy.ts, copy and paste the code below

  // We require the Hardhat Runtime Environment explicitly here. This is optional
// but useful for running the script in a standalone fashion through `node <script>`.
//
// When running the script with `npx hardhat run <script>` you'll find the Hardhat
// Runtime Environment's members available in the global scope.
import { ethers } from "hardhat";

async function main() {
  // Hardhat always runs the compile task when running scripts with its command
  // line interface.
  //
  // If this script is run directly using `node` you may want to call compile
  // manually to make sure everything is compiled
  // await hre.run('compile');

//   run this first to get the contract address
//   after getting the contract address, 
//   you can comment it out and past your address in line 25(address argument)
  const Bank = await ethers.getContractFactory("KlayBank");
  const bank = await Bank.deploy();
  await bank.deployed();
  console.log("Bank deployed to:", bank.address);  

}

// We recommend this pattern to be able to use async/await everywhere
// and properly handle errors.
main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

We are leaving localhost to testnet this time, run the command below to deploy

npx hardhat run scripts/deploy.ts --network klaytn

hardhat_deploy.png

Paste your contract address on KlaytnScope to confirm your transaction. This is mine below 👇

contractDeploy.png

Interacting with your deployed smart contract


// We require the Hardhat Runtime Environment explicitly here. This is optional
// but useful for running the script in a standalone fashion through `node <script>`.
//
// When running the script with `npx hardhat run <script>` you'll find the Hardhat
// Runtime Environment's members available in the global scope.
import { ethers } from "hardhat";

async function main() {
  // Hardhat always runs the compile task when running scripts with its command
  // line interface.
  //
  // If this script is run directly using `node` you may want to call compile
  // manually to make sure everything is compiled
  // await hre.run('compile');

//   run this first to get the contract address
//   after getting the contract address, 
//   you can comment it out and past your address in line 25(address argument)
  // const Bank = await ethers.getContractFactory("KlayBank");
  // const bank = await Bank.deploy();
  // await bank.deployed();
  // console.log("Bank deployed to:", bank.address);

  const depositorsAddr = "the address with the private key in your .env"
  const depositor1 = await ethers.getSigner(depositorsAddr);
  const bankInstance = await ethers.getContractAt("KlayBank", "0x190aDc2C6E395c9D25AB598e7F101aAeFe28457B");

  const bal4  = await bankInstance.getBalance(depositorsAddr);
  console.log(`balance of depositor1 before is ${bal4}`);

  const option = {value: ethers.utils.parseEther("1")}
  const deposit = await bankInstance.connect(depositor1).deposit(option);
  const tx = await deposit.wait();
//   console.log(tx);

// Note you can change this address to another live address
 const beneficiaryAddr = "0x2792f4C16F124942886DF20f3C5B4c2cB195aEe2"
 const balAfter  = await bankInstance.getBalance(depositorsAddr);
 console.log(`balance of depositor1 after is ${balAfter}`);

 const balBenerficiaryb4  = await bankInstance.getBalance(beneficiaryAddr);
 console.log(`balance of beneficiary before is ${balBenerficiaryb4}`);

 const withdraw = await bankInstance.connect(depositor1).withdraw(beneficiaryAddr, "5000000000")
 const tx2 = await withdraw.wait();
//  console.log(tx2);

 const balBenerficiaryAfter  = await bankInstance.getBalance(beneficiaryAddr);
 console.log(`balance of beneficiary after is ${balBenerficiaryAfter}`);

 const balDepositor1AfterWithdrawal  = await bankInstance.getBalance(depositorsAddr);
 console.log(`balance of depositor1 after  withdrawal is ${balDepositor1AfterWithdrawal}`);


}

// We recommend this pattern to be able to use async/await everywhere
// and properly handle errors.
main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

Now to interact with the contract run the deploy scripts again

npx hardhat run scripts/deploy.ts --network klaytn

The result would be displayed in your console 💪👩‍💻

We have successfully written, compiled, tested and deployed our smart contract using hardhat considering Klaytn fixed gas price model.

Now let's consider sending a transaction using ethers js putting in mind the klaytn fixed gas price model

Sending a transaction using ethers js

Now let us navigate to our scripts folder and create a new file

scripts/ethersTx.ts

Copy and paste this code into your newly created file

  // We require the Hardhat Runtime Environment explicitly here. This is optional
// but useful for running the script in a standalone fashion through `node <script>`.
//
// When running the script with `npx hardhat run <script>` you'll find the Hardhat
// Runtime Environment's members available in the global scope.
import { ethers } from "hardhat";
import * as dotenv from "dotenv";
import { BytesLike } from "ethers/lib/utils";


dotenv.config();


async function main() {
const account = "0x2792f4C16F124942886DF20f3C5B4c2cB195aEe2";
const url = process.env.KLAYTN_URL;
// @ts-ignore
const priv : BytesLike = process.env.PRIVATE_KEY;
const provider = new ethers.providers.JsonRpcProvider(url)
const wallet = new ethers.Wallet(priv, provider)

const tx = await wallet.sendTransaction({
           to: account,
           value: 90000000000,
           gasPrice: 250000000000,
           gasLimit: 21000,
       })

const receipt = await tx.wait()
console.log(receipt);


}

// We recommend this pattern to be able to use async/await everywhere
// and properly handle errors.
main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

Code walkthrough

The line const wallet = new ethers.Wallet(priv, provider) sets the instance of the wallet given a private key and a provider.

The line

const tx = await wallet.sendTransaction({ to: account, value: 90000000000, gasPrice: 250000000000, gasLimit: 21000, })

sends a transaction to the network. Note we set our gasPrice to Klaytn fixed gas price 250000000000

Lets run this code

npx hardhat run scripts/ethersTx.ts --network klaytn

Without setting the gasPrice to 250000000000 we get this error

errorKlaytn.png

When we set the gas price to the fixed gasPrice, our transaction becomes successful

success.png

Conclusion

Congratulations for making it until the end and for setting up your klaytn Hardhat project

This was a very comprehensive tutorial on writing, compiling, testing, and deploying our solidity smart contract to Klaytn Baobab using Hardhat and Ethers js considering Klaytn fixed gas price model. You can read more about Klaytn support for Ethereum equivalence here

You can check out the complete source code here on github

If you have any questions, suggestions or comments, drop them below, or reach out to me on Twitter!

Happy Buidling!!!!