Efficient Deployment of ERC721 Contracts using Hardhat Runtime Environment: A Step-by-Step Guide

Introduction

Hey my favorite reader😊, It’s been a minute. This tutorial is part of a bigger series of building NFT Marketplaces. You can learn more here
In this tutorial, we would be looking at deploying ERC721 contracts using the hardhat runtime environment(HRE). This is going to serve as an introduction to hardhat but also help guide you to how to work with it in a full-scale dApp.

Let’s begin :rocket::tada:

What is HRE(Hardhat Runtime Environment)?

The Hardhat Runtime Environment (HRE) is a framework and toolset designed to facilitate the development and testing of Celo smart contracts. It provides a local development environment that simulates the Celo network and enables developers to interact with smart contracts without deploying them to the live blockchain.

The HRE is built on top of the Hardhat development framework, which is specifically tailored for Celo development. It integrates with Celo client libraries to create a local blockchain network that runs on the developer’s machine. This local network behaves similarly to the Celo mainnet or testnets, allowing developers to test and debug their smart contracts in a controlled environment.

The HRE provides a set of powerful features and utilities to streamline the development process. It offers an interactive console where developers can execute commands and interact with deployed smart contracts, inspect transaction details, and retrieve blockchain data. It also includes a testing framework that allows developers to write unit tests for their contracts and run them locally before deploying to the actual Celo network.

For more information on Hardhat and how it works, follow the official documentation

Prerequisites

  • Prior knowledge of javascript
  • Familiarity with the command line
  • Basic understanding of blockchain concepts
  • Have some knowledge of solidity and its concepts

Requirements

  • NodeJS from V12.or higher
  • A code editor or text editor. VSCode is recommended
  • A terminal. Git Bash is recommended
  • An Internet Browser and a good internet connection
  • Hardhat

Context

To provide context to the contract code being referenced throughout the tutorial, here is the code:

MyNFT.sol

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Enumerable.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/Counters.sol";

contract MyNFT is ERC721, Ownable, ERC721Enumerable, ERC721URIStorage {
    using Counters for Counters.Counter;

    Counters.Counter private _tokenIdCounter;
    address contractAddress;

    constructor(address marketplace) ERC721("Dripto Ponks", "DPT") {
        contractAddress = marketplace;
    }

    uint256 _tokenId = 0;

    function mint(string memory uri) external returns (uint256) {
        _tokenId++;
        _mint(msg.sender, _tokenId);
        _setTokenURI(_tokenId, uri);
        setApprovalForAll(contractAddress, true);
        return _tokenId;
    }

    function resaleApproval(uint256 tokenId) public {
        require(
            ownerOf(tokenId) == msg.sender,
            "You must own this NFT in order to resell it"
        );
        setApprovalForAll(contractAddress, true);
        return;
    }

    function getTokenId() external view returns (uint256) {
        return _tokenId;
    }

    // The following functions are overrides required by Solidity.

    function _beforeTokenTransfer(
        address from,
        address to,
        uint256 tokenId
    ) internal override(ERC721, ERC721Enumerable) {
        super._beforeTokenTransfer(from, to, tokenId);
    }

    function _burn(uint256 tokenId)
        internal
        override(ERC721, ERC721URIStorage)
    {
        super._burn(tokenId);
    }

    function tokenURI(uint256 tokenId)
        public
        view
        override(ERC721, ERC721URIStorage)
        returns (string memory)
    {
        return super.tokenURI(tokenId);
    }

    function supportsInterface(bytes4 interfaceId)
        public
        view
        override(ERC721, ERC721Enumerable)
        returns (bool)
    {
        return super.supportsInterface(interfaceId);
    }
}

MyNftMarket.sol

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "@openzeppelin/contracts/token/ERC721/IERC721.sol";

contract MyNftMarket {
	struct Listing {
		address payable seller;
        address payable owner;
		address token;
		uint tokenId;
		uint price;
        bool sold;
	}

	uint private _listingId = 0;
	mapping(uint => Listing) private _listings;

	function listToken(address token, uint tokenId, uint price) external {
		IERC721(token).transferFrom(msg.sender, address(this), tokenId);

		Listing memory listing = Listing(
			payable(msg.sender),
            payable(address(this)),
			token,
			tokenId,
			price,
            false
		);
		_listings[_listingId] = listing;
		_listingId++;

	}

	function getListing(uint listingId) public view returns (Listing memory) {
		return _listings[listingId];
	}

    function getListingLength()public view returns (uint){
        return _listingId;
    }

	function buyToken(uint listingId) public payable {
		Listing storage listing = _listings[listingId];

		require(msg.value == listing.price, "Insufficient payment");

		IERC721(listing.token).transferFrom(listing.owner, msg.sender, listing.tokenId);
		payable(listing.seller).transfer(msg.value);

        listing.sold = true;
        listing.price = listing.price * 2;
        listing.owner = payable(msg.sender);
		listing.seller = payable(msg.sender);

	}
}

Getting Started

To install Hardhat and configure it for your project, follow these steps:

Open your terminal and navigate to your project directory. In this example, let’s assume the project directory is named dripto-ponks. So, in the terminal, use the cd command to change your current directory to dripto-ponks.

cd dripto-ponks

Make sure your project has an npm package.json file. If it doesn’t, initialize npm in your project directory by running the following command. This command creates a package.json file with default settings.

npm init -y

Install Hardhat by executing the following command. This will download and install the Hardhat package, saving it as a dev dependency in your project.

npm install --save-dev hardhat

Now we need to install the dependencies as suggested in the configuration.

```bash
npm install --save-dev @nomiclabs/hardhat-ethers ethers @nomiclabs/hardhat-waffle ethereum-waffle chai

```

After the installation, you’ll need to configure Hardhat for your specific project requirements. Generate a hardhat.config.js file in your project directory by running the following command:

npx hardhat

Open the hardhat.config.js file in a text editor and make the necessary modifications to suit your project. This file allows you to define settings such as network configurations, compilers, and plugin integrations. Refer to the Hardhat documentation for detailed information on the available configuration options.

In our case, we would edit the module.exports to

require("@nomiclabs/hardhat-waffle");
require('dotenv').config({path: '.env'});

// This is a sample Hardhat task. To learn how to create your own go to
// https://hardhat.org/guides/create-task.html
task("accounts", "Prints the list of accounts", async (taskArgs, hre) => {
  const accounts = await hre.ethers.getSigners();

  for (const account of accounts) {
    console.log(account.address);
  }
});

// You need to export an object to set up your config
// Go to https://hardhat.org/config/ to learn more

/**
 * @type import('hardhat/config').HardhatUserConfig
 */
module.exports = {
  networks: {
    alfajores: {
      url: "https://alfajores-forno.celo-testnet.org",
      accounts: {
        mnemonic: process.env.MNEMONIC,
        path: "m/44'/52752'/0'/0"
      },
    }
  },
  solidity: "0.8.4",
};

To read the mnemonic from the environment variable, we’ll be using the dotenv library. Therefore, let’s include the dotenv library at the top of the file to facilitate this functionality.

Let’s install dotenv

npm install dotenv

You’ll notice that we are importing a MNEMONIC, which is a list of words used to access crypto assets, in order to gain access to a Celo account (line 97). This is essential for interacting with the Celo Alfajores testnet. To ensure the security of the mnemonic, we will store it in an environment variable.

It’s important to mention that the derivation path (line 98) may vary depending on how the mnemonic was created. The current configuration assumes that the account was created with the Metamask wallet. If the account was created with the CeloExtensionWallet, for example, the path would be “m/44’/52752’/0’/0”.

To retrieve the mnemonic from Metamask, follow these steps:

  • Open Metamask and click on the identicon (account avatar).
  • Go to “Settings”.
  • Select “Security & Privacy”.
  • Click on “Reveal Secret Recovery Phrase” or “Reveal Seed Phrase”.
  • Copy the displayed phrase.

Next, create a new file in the main directory of your project called .env. This file will be used to securely store your mnemonic. Inside the .env file, add the mnemonic using the following format:

MNEMONIC=your-retrieved-mnemonic-goes-here

Replace your-retrieved-mnemonic-goes-here with the actual mnemonic you obtained from Metamask. This .env file will facilitate the loading of the mnemonic into your project using environment variables.

To read the mnemonic from the environment variable, we will utilize the dotenv library. Make sure to include the dotenv library at the top of your file to enable this functionality.

To test if everything worked as intended, try to deploy the token to Alfajores via our terminal.

npx hardhat test

Compiling Smart Contracts

In the scripts/deploy.js file, we would start writing code to compile our marketplace smart contract.

We would setup the environment, get the Contract Factory for our specific contract, and deploy it to the Celo network using Hardhat and ethers.js.

deploy.js

const hre = require("hardhat");

async function main() {
  // market contract
  const MyNftMarket = await hre.ethers.getContractFactory("MyNftMarket");
  const market = await MyNftMarket.deploy();

First, we import the Hardhat Runtime Environment using the require("hardhat") statement. Hardhat provides a set of tools and functionality for Celo development.

Next, we define an asynchronous function named main(). This function will serve as the entry point for our deployment script.

Inside the main() function, we proceed with deploying a smart contract. In this example, we are deploying a contract called MyNftMarket.

We use hre.ethers.getContractFactory("MyNftMarket") to obtain the Contract Factory for the MyNftMarket contract. The Contract Factory allows us to create instances of the contract.

Then, we use MyNftMarket.deploy() to deploy the contract to the Celo network. This function initiates the deployment process and returns a Promise that resolves to a Contract instance representing the deployed contract.

By assigning the result of MyNftMarket.deploy() to the market variable, we store the deployed contract instance for further interactions and usage in our script.

After deploying the MyNftMarket contract to the network using MyNftMarket.deploy(), we need to wait for the deployment to complete by calling await market.deployed(). This ensures that the contract deployment transaction is confirmed and the contract instance is fully deployed and available for interaction.

  await market.deployed();

  console.log("NftMarketPlace", market.address);
  storeMarketData(market);

Once the deployment is confirmed, we can access the deployed contract’s address using market.address. The console.log("NftMarketPlace", market.address) statement outputs the contract’s address to the console. This allows us to easily identify the address where the contract has been deployed.

In the next portion of the code, we are deploying another smart contract called MyNFT and interacting with it.

  const MyNFT = await hre.ethers.getContractFactory("MyNFT");
  const myNFT = await MyNFT.deploy(market.address);

  await myNFT.deployed();

  console.log("MyNFT deployed to:", myNFT.address);
  storeContractData(myNFT);

First, we use hre.ethers.getContractFactory("MyNFT") to obtain the Contract Factory for the MyNFT contract, similar to what we did for the MyNftMarket contract.

Then, we use MyNFT.deploy(market.address) to deploy the MyNFT contract to the network. The market.address parameter passed to the deploy() function indicates that we are passing the address of the previously deployed MyNftMarket contract as an argument during deployment. This allows the “MyNFT” contract to interact with the MyNftMarket contract.

We wait for the deployment to complete by calling await myNFT.deployed(), ensuring that the deployment transaction is confirmed and the contract instance is fully deployed.

Next, we use console.log("MyNFT deployed to:", myNFT.address) to display the address where the MyNFT contract has been deployed in the console.

There is a reference to a storeContractData function, which is not explicitly defined in the provided code. This function is responsible for storing or handling the deployed contract data.

We then have a function called storeContractData that is responsible for storing the deployed contract data. These files can be used for contract interaction and reference in the future. This has been referenced earlier in the codebase

function storeContractData(contract) {
  const fs = require("fs");
  const contractsDir = __dirname + "/../src/contracts";

  if (!fs.existsSync(contractsDir)) {
    fs.mkdirSync(contractsDir);
  }

  fs.writeFileSync(
    contractsDir + "/MyNFT-address.json",
    JSON.stringify({ MyNFT: contract.address }, undefined, 2)
  );

  const MyNFTArtifact = artifacts.readArtifactSync("MyNFT");

  fs.writeFileSync(
    contractsDir + "/MyNFT.json",
    JSON.stringify(MyNFTArtifact, null, 2)
  );
}

The code imports the fs module, which provides access to the file system. This module is used to read and write files.

The variable contractsDir is set to the directory path where the contract-related files will be stored. It is located in the parent directory (../src/contracts) relative to the current script file’s location (__dirname).

The code checks if the contractsDir directory exists using fs.existsSync(contractsDir). If the directory does not exist, it creates it using fs.mkdirSync(contractsDir).

Next, the code writes the deployed contract’s address to a JSON file named MyNFT-address.json inside the contractsDir. The address is stored as a property within an object { MyNFT: contract.address }. The JSON.stringify() function is used to convert the object into a JSON string with an indentation of 2 spaces.

After that, the code reads the artifact file for the MyNFT contract using artifacts.readArtifactSync("MyNFT"). The artifact file contains contract-related metadata such as its ABI (Application Binary Interface) and other relevant information.

The code then writes the artifact file to a JSON file named MyNFT.json inside the contractsDir. Again, JSON.stringify() is used to convert the artifact object to a JSON string with an indentation of 2 spaces.

We have another function called storeMarketData that is responsible for storing the deployed market contract data. These files can be used for contract interaction and reference in the future, similar to the previous function.

function storeMarketData(contract) {
  const fs = require("fs");
  const contractsDir = __dirname + "/../src/contracts";

  if (!fs.existsSync(contractsDir)) {
    fs.mkdirSync(contractsDir);
  }

  fs.writeFileSync(
    contractsDir + "/MyNFTMarket-address.json",
    JSON.stringify({ MyNFTMarket: contract.address }, undefined, 2)
  );

  const MyNFTArtifact = artifacts.readArtifactSync("MyNftMarket");

  fs.writeFileSync(
    contractsDir + "/MyNFTMarket.json",
    JSON.stringify(MyNFTArtifact, null, 2)
  );
}

Similar to the previous function, we begin by importing the fs module, which provides file system access.

The variable contractsDir is set to the directory path where the contract-related files will be stored. It is assumed to be located in the parent directory (../src/contracts) relative to the current script file’s location (__dirname).

The code checks if the contractsDir directory exists using fs.existsSync(contractsDir). If the directory does not exist, it creates it using fs.mkdirSync(contractsDir).

Next, the code writes the deployed market contract’s address to a JSON file named MyNFTMarket-address.json inside the contractsDir. The address is stored as a property within an object { MyNFTMarket: contract.address }. The JSON.stringify() function is used to convert the object into a JSON string with an indentation of 2 spaces.

After that, the code reads the artifact file for the MyNftMarket contract using artifacts.readArtifactSync("MyNftMarket"). The artifact file contains contract-related metadata such as its ABI (Application Binary Interface) and other relevant information.

The code then writes the artifact file to a JSON file named MyNFTMarket.json inside the contractsDir. Again, JSON.stringify() is used to convert the artifact object to a JSON string with an indentation of 2 spaces.

In the final part of the code, we have the last section that handles the execution of the deployment script. This part of the code ensures that the script exits gracefully, either with a success status code if the deployment completes without errors or with a failure status code if an error occurs during the deployment process. The error message, if any, is logged to the console for debugging purposes.

main()
  .then(() => process.exit(0))
  .catch((error) => {
    console.error(error);
    process.exit(1);
  });

The main() function, which is the entry point of our deployment script, is invoked. The main() function is expected to be an asynchronous function that returns a Promise.

After calling main(), we attach .then() to the returned Promise. If the Promise is resolved successfully (i.e., the deployment process completes without any errors), the callback function inside .then() is executed. In this case, the callback simply calls process.exit(0) to exit the script with a success status code (0).

On the other hand, if the Promise is rejected (i.e., an error occurs during the deployment process), the callback function inside .catch() is executed. This callback function receives the error object as an argument and logs the error message using console.error(error). Then, it calls process.exit(1) to exit the script with a failure status code (1).

Now, to compile our contracts, we can run:

npx hardhat compile

For testing our contracts, we have already written tests using mocha and chai which I covered in a previous tutorial, check it out here

Deploying Contract to Alfajores

To deploy our contract, we use the command:

npx hardhat run --network alfajores scripts/deploy.js

This command provides a straightforward way to deploy and test your smart contracts on the Celo Alfajores network during your development process.

Full Code

const hre = require("hardhat");

async function main() {
  // market contract
  const MyNftMarket = await hre.ethers.getContractFactory("MyNftMarket");
  const market = await MyNftMarket.deploy();

  await market.deployed();

  console.log("NftMarketPlace", market.address);
  storeMarketData(market);

  const MyNFT = await hre.ethers.getContractFactory("MyNFT");
  const myNFT = await MyNFT.deploy(market.address);

  await myNFT.deployed();

  console.log("MyNFT deployed to:", myNFT.address);
  storeContractData(myNFT);

}

function storeContractData(contract) {
  const fs = require("fs");
  const contractsDir = __dirname + "/../src/contracts";

  if (!fs.existsSync(contractsDir)) {
    fs.mkdirSync(contractsDir);
  }

  fs.writeFileSync(
    contractsDir + "/MyNFT-address.json",
    JSON.stringify({ MyNFT: contract.address }, undefined, 2)
  );

  const MyNFTArtifact = artifacts.readArtifactSync("MyNFT");

  fs.writeFileSync(
    contractsDir + "/MyNFT.json",
    JSON.stringify(MyNFTArtifact, null, 2)
  );
}

function storeMarketData(contract) {
  const fs = require("fs");
  const contractsDir = __dirname + "/../src/contracts";

  if (!fs.existsSync(contractsDir)) {
    fs.mkdirSync(contractsDir);
  }

  fs.writeFileSync(
    contractsDir + "/MyNFTMarket-address.json",
    JSON.stringify({ MyNFTMarket: contract.address }, undefined, 2)
  );

  const MyNFTArtifact = artifacts.readArtifactSync("MyNftMarket");

  fs.writeFileSync(
    contractsDir + "/MyNFTMarket.json",
    JSON.stringify(MyNFTArtifact, null, 2)
  );
}

main()
  .then(() => process.exit(0))
  .catch((error) => {
    console.error(error);
    process.exit(1);
  });

Conclusion

In this tutorial, we explored the process of installing and configuring Hardhat, a popular development environment for Celo smart contracts. We walked through the steps required to set up Hardhat, including the installation process and project initialization.

We learned how to write a deployment script using Hardhat’s JavaScript API. We examined the code snippets that demonstrated the deployment of a Celo smart contract and the storage of contract data.

Throughout the tutorial, we gained an understanding of key concepts such as the Hardhat Runtime Environment (HRE), which provides a powerful interface for interacting with the Celo network during contract deployment.

Next Steps

This tutorial is to serve as an explanatory guide to writing ERC721 smart contracts. You could see the full project here

About the Author

Daniel Ogbuti is a web3 developer with a passion for teaching as well as learning. I would love to connect on Twitter @daniel_ogbuti and linkedin: Daniel Ogbuti

See you soon!

3 Likes

Congratulations on being among the highest voted proposals for this week! I’m moving this to todo so that you can get started on this project. :mortar_board: :seedling:

2 Likes

Hi @danielogbuti i will be reviewing this

1 Like

looks good, you can move to publish

1 Like