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
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!