Celo Development 201 - NFT Contract Development with Hardhat

This learning module consists of a tutorial that will teach you how to develop, test and deploy smart contracts with Hardhat to the Celo testnet Alfajores. We will use an NFT contract as an example.

Prerequisites

You will need the following for this learning module:

  • Node JS - Please ensure you have Node.js v12 or higher installed.
  • Basic understanding of NFT contracts as described in our Intro to NFT Contract Development learning module.
  • Be comfortable using a terminal.

Tech Stack

In this tutorial, we will use the following tools and technologies:

Hardhat has become the most popular development environment for smart contracts. We can use it to compile, test, debug and deploy our smart contracts.

We can interact with Hardhat via its CLI to run tasks. There are many built-in tasks that we can use, e.g., npx hardhat compile to compile contracts, but we can also create our own tasks to optimize our workflow.

Hardhat is built with a modular approach, where most of its functionality comes from its plugins. For example, we could test with ethers.js and waffle or with web3.js and truffle or use completely different tools. Here is a list of the official plugins: https://hardhat.org/plugins/.

Hardhat also has a local blockchain built-in, the Hardhat Network, with which we can easily test and debug our contracts.

Let's set up our Hardhat environment next.

1. Project Setup

Open your terminal and check your node version.

node -v

If you have a version of node.js that is lower than 12, please upgrade node.js.

We create a new directory for this project.

mkdir celo-hardhat

We navigate into the directory.

cd celo-hardhat

We create a new package.json file.

npm init

We install Hardhat.

npm install hardhat

We run hardhat.

npx hardhat

During the configuration, select "Create a basic sample project" and use the preselected settings.

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

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

We can get an overview of the built-in Hardhat tasks with:

npx hardhat help

1.2 Celo Configuration

We want to enable Hardhat to deploy to the Celo network, in this case to the Alfajores testnet. To do that, we need to add a network entry to our hardhat.config.js file located at the project's root directory.

Change your module.exports to:

// ...
module.exports = {
  solidity: "0.8.4",
  networks: {
    alfajores: {
      url: "https://alfajores-forno.celo-testnet.org",
      accounts: {
        mnemonic: process.env.MNEMONIC, // line 25
        path: "m/44'/60'/0'/0", // line 26
      },
      chainId: 44787,
    },
  },
};

You can see that we are importing a MNEMONIC (A list of words to access crypto assets) to get access to a Celo account (line 25). We need to get access to a Celo account to interact with the Celo Alfajores testnet. We will store our mnemonic in an environment variable in a second.

One other thing that is important to mention here is that the derivation path (line 26) changes depending on how your mnemonic is created. This configuration is for an account created with the Metamask wallet. For an account created with the CeloExtensionWallet the path would be path: "m/44'/52752'/0'/0" for example.

We will need dotenv to read our mnemonic from our environment variable so let's require it on top of the file.

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

Here is the complete code for our hardhat.config.js file.

Let's install dotenv.

npm install dotenv

For the next part, you will need to have Metamask installed with an account that has Alfajores testnet tokens.

In the Intro to NFT Contract Development learning module, you learn how to create an account via Metamask and how to get testnet tokens for it via the Celo faucet. You will need to do this for the next part.

In Metamask, you can click on the identicon, go to settings, select "Security & Privacy ", click on "Reveal Secret Recovery Phrase", and copy that phrase.

Create a new file in your main directory called .env. Inside the .env file, store your mnemonic. It should look like this:

MNEMONIC="YOUR_SECRET_RECOVERY_PHRASE"

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

npx hardhat run scripts/sample-script.js --network alfajores

If everything worked correctly, Hardhat should return the address to which the contract was deployed. You should see something like this:

Compiling 2 files with 0.8.4
Solidity compilation finished successfully
Greeter deployed to: 0x454E8E8c37820D84351CCfa34AbCCc03870Fc1fE

2. Write and Compile Smart Contracts

This section looks into the example greeter contract and compiles it.

2.1 Greeter Contract

Inside the contracts/Greeter.sol file that is part of the sample project, you should see the following code.

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

import "hardhat/console.sol";

contract Greeter {
    string private greeting; // line 7

    constructor(string memory _greeting) {
        console.log("Deploying a Greeter with greeting:", _greeting);
        greeting = _greeting; // line 11
    }

    function greet() public view returns (string memory) { // line 14
        return greeting;
    }

    function setGreeting(string memory _greeting) public { // line 18
        console.log("Changing greeting from '%s' to '%s'", greeting, _greeting);
        greeting = _greeting;
    }
}

At this point, we expect you to be familiar with Solidity contract development; otherwise, look at our Celo Development 101 course.

This is a very simple contract that stores a greeting string (line 7) set in the constructor when deploying the contract (line 11).
We also have a function greet that returns the string (line 14) and a function setGretting that allows us to set a new value for greeting (line 18).

To easier debug our Solidity contracts, Hardhat allows us to log messages and variables via console.log() (lines 10 and 19). We will see the messages later when we run our tests.

2.2 Compiling

Before we compile our contract, we need to remove all artifacts that we created when we deployed our contract in the last section to see better what is happening.

We use Hardhat's built-in clean task for that:

npx hardhat clean

We can use the built-in compile task that we used earlier to compile our contracts:

npx hardhat compile

By default, the compiled artifacts will be saved in the artifacts directory. You might want to change this later if you want to create a special folder for your Dapp to access the ABI, for example.

After compiling your contract, you should be able to find the ABI and the compiled byte code in the artifacts/contracts/greeter.sol/greeter.json file.

Let's take a look at how to test our contract.

3. Testing Smart Contracts

Creating automated tests is very important when writing smart contracts since they are immutable, and the data stored in them is often very valuable.

In this tutorial, we will use the Waffle library for our tests. Waffle works with ethers.js and claims to be "Sweeter, simpler and faster than Truffle".

Tests in Waffle are written with the popular JavaScript test framework Mocha and the assertion library Chai.

3.1 Testing File

The tests that we execute can be found in a directory called test. We can already find here an example file called test/sample-test.js. Let's open it.

In the first lines, we require expect from chai and ethers from hardhat.

const { expect } = require("chai");
const { ethers } = require("hardhat");
// ...

Waffle makes it easier for us to assert Ethereum values. Have a look at Waffles documentation of Chai matchers (asserting functions) that you can use.

// ...
describe("Greeter", function () {
  it("Should return the new greeting once it's changed", async function () {
// ...

We use Mocha's testing structure. We use describe and it functions to test our expectations.
In this case, we want to test our greeter function and expect it to return the new greeting after we change it.

Let's start the test by creating a new contract instance:

// ...
const Greeter = await ethers.getContractFactory("Greeter");
// ...

We need more information than the Solidity contract code to deploy a contract, e.g., the compiled bytecode. The ContractFactory is an ethers.js abstraction that helps us deploy the contract with the needed information.

Now we can deploy the contract:

// ...
const greeter = await Greeter.deploy("Hello, world!");
await greeter.deployed();

// ...

We use the deploy function of the contract factory to deploy the contract, passing the constructor arguments ("Hello, world!"). It returns a Promise that resolves to the contract instance greeter once it's deployed.

Then we use the await keyword to wait for the deployed function to resolve once the contract is successfully deployed.

After the contract is deployed, we can call its functions:

// ...
expect(await greeter.greet()).to.equal("Hello, world!");
// ...

Here we call the greet function of the contract and use Chai's expect function to assert that the returned value is equal to the expected value.

Now we can change the greeting and test it again:

// ...
    const setGreetingTx = await greeter.setGreeting("Hola, mundo!");

    // wait until the transaction is mined
    await setGreetingTx.wait();

    expect(await greeter.greet()).to.equal("Hola, mundo!");
  });
});

This time we call the setGreeting function of the contract and wait for the transaction to be mined. Then we assert that the returned value is equal to the expected value.

Now we can run our tests.

3.2 Running tests

We can run our tests with the built-in test task:

npx hardhat test

You should then see something like the following output:

  Contract: Greeter
    ✓ Should return the new greeting once it's changed (762ms)

  1 passing (762ms)

With the last command, we ran our test on the built-in Hardhat Network, a local Ethereum network that allows us to test and debug our smart contracts.

This is not the Celo blockchain; if you want to run the Celo blockchain locally, you should have a look at Celo Ganache and how to run a Loca Development Chain.

For this tutorial, we will test our contracts locally on the Hardhat Network and then on the Celo Alfajores testnet.

3.3 Running Tests on Alfajores

If you have configured Alfajores, in the hardhat.config.js file, you can run your tests on the Alfajores testnet with the following command:

npx hardhat test --network alfajores

Since we are connecting and deploying our contracts on the Alfajores testnet, it will take a little bit longer for the tests to run.

After the tests are finished, you should see something like the following output:

  Greeter
    √ Should return the new greeting once it's changed (20547ms)


  1 passing (21s)

4. Deploying Smart Contracts

To deploy a smart contract to the Celo blockchain, we use a deployment script called. You can find the deployment script in the sample-script.js file in the scripts directory.

This deployment functionality in this script is almost the same as the one we used in the previous section when we tested our contract.

const hre = require("hardhat");

async function main() {
  const Greeter = await hre.ethers.getContractFactory("Greeter");
  const greeter = await Greeter.deploy("Hello, Hardhat!");

  await greeter.deployed();

  console.log("Greeter deployed to:", greeter.address);
}

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

We require the hre (Hardhat Runtime Environment) and define a function called main. We create a new instance of the ContractFactory and call the deploy function of the contract factory to deploy the contract. When the contract is deployed, we display a message with the contract address.

Now we can run our deployment script.

4.1 Deploying to Local Network

We can start a local built-in blockchain with the following command:

npx hardhat node

Upon starting the local node, we get a number of accounts with Ether to use. You should see something like the following output:

Started HTTP and WebSocket JSON-RPC server at http://127.0.0.1:8545/

Accounts
========

WARNING: These accounts, and their private keys, are publicly known.
Any funds sent to them on Mainnet or any other live network WILL BE LOST.

Account #0: 0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266 (10000 ETH)
Private Key: 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80

You should see 19 more accounts with Ether.

Create a new terminal and run the following command to deploy the contract to the local node:

npx hardhat run --network localhost scripts/sample-script.js

You should see something like the following output:

Greeter deployed to: 0x5FbDB2315678afecb367f032d93F642f64180aa3

Now we can open a Hardhat console and connect to the local node:

npx hardhat console --network localhost

We can use this console to interact with the contract locally. Let's get the contractFactory of the greeter contract:

const Greeter = await ethers.getContractFactory('Greeter');

You will see an undefined value as an output.

Next, we want to get the instance of the contract:

const greeter = await Greeter.attach('0x5FbDB2315678afecb367f032d93F642f64180aa3')

Use the address that your contract was deployed to.
You will again see an undefined value as an output.

Now we can call our contract's functions:

await greeter.greet()

You should see the following output:

'Hello, Hardhat!'

We can also call the setGreeting function of the contract:

await greeter.setGreeting('Gm')

Now we can call the greet function again:

await greeter.greet()

And should see the following output:

'Gm'

4.2 Deploying to Alfajores

Deploying to the Alfajores testnet is very similar to deploying to the local network. The commands are the same, but we need to specify the network as alfajores.

Run deploy script to deploy the contract to the Alfajores testnet:

npx hardhat run --network alfajores scripts/sample-script.js

Open a Hardhat console and connect to the Alfajores testnet:

npx hardhat console --network alfajores

Get the contractFactory of the greeter contract:

const Greeter = await ethers.getContractFactory('Greeter');

Get the instance of the contract:

const greeter = await Greeter.attach('0xa7597BDf14AB9b3E493257A79b4D1724A93A5E3a')

Use the address that your contract was deployed to on Alfajores.

Get the greeting:

await greeter.greet()

You can open the Celo Blockchain Explorer to see the contract on the Alfajores testnet: https://alfajores-blockscout.celo-testnet.org/address/0xa7597BDf14AB9b3E493257A79b4D1724A93A5E3a/read-contract

Change the address (0xa7597BDf14AB9b3E493257A79b4D1724A93A5E3a) to the address of your contract, and you can see the contract and the greeting on the Alfajores testnet.

5. Writing an NFT Contract

In this section, we will create an NFT contract based on the ERC721 standard.

Let's clean up before we start:

npx hardhat clean

Delete the example contract contracts/Greeter.sol:

rm contracts/Greeter.sol

For this section, we expect you to have gone through the Intro to NFT Contract Development learning module and are familiar with NFT contracts.

We used Openzeppelin's Contract Wizard to create a secure contract that implements the ERC721 standard. The contract implements some additional features:

  • It has a mint function that allows the owner to mint new tokens that automatically increment their token ID because we want to be able to mint tokens after the contract has been deployed and don't want to have to manually increment the token ID.

  • Enumerable, allows us to list all the tokens that have been minted.

  • URI Storage, allows us to store a URI for the metadata of each token.

Create a new contract file contracts/MyNFT.sol and copy the following code into it:

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

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, ERC721Enumerable, ERC721URIStorage, Ownable {
    using Counters for Counters.Counter;

    Counters.Counter private _tokenIdCounter;

    constructor() ERC721("MyNFT", "MNFT") {}

    function safeMint(address to, string memory uri) public onlyOwner {
        uint256 tokenId = _tokenIdCounter.current();
        _tokenIdCounter.increment();
        _safeMint(to, tokenId);
        _setTokenURI(tokenId, uri);
    }

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

Most of the functions should look familiar to you if you went through the Intro to NFT Contract Development learning module. There are multiple overrides that we need to implement here, because some of the functions are defined multiple times by the contracts we are inheriting from.

The most important part of the contract is the safeMint function:

// ...
    function safeMint(address to, string memory uri) public onlyOwner {
        uint256 tokenId = _tokenIdCounter.current();
        _tokenIdCounter.increment();
        _safeMint(to, tokenId);
        _setTokenURI(tokenId, uri);
    }
// ...

Only the owner of the contract can mint new tokens. They need to provide the address of the recipient and the URI of the token. The tokenId will be automatically assigned by the contract.

Before we can test the contract, we need to install the OpenZeppelin contracts that we are using:

npm install @openzeppelin/contracts

Let's compile the contract to make sure everything works:

npx hardhat compile

Great, now we can write some tests for our contract.

6. Testing NFT Contract

Hopefully, you remember how tests work from the previous sections.
Let's first delete the test/sample-test.js file:

rm test/sample-test.js

And create a new test/nft-test.js file with the following code:

const { expect } = require("chai");
const { ethers } = require("hardhat");

describe("MyNFT", function () {
  this.timeout(50000); // line 5

  let myNFT;
  let owner;
  let acc1;
  let acc2;

  this.beforeEach(async function() { // line 12
      const MyNFT = await ethers.getContractFactory("MyNFT");
      [owner, acc1, acc2] = await ethers.getSigners();

      myNFT = await MyNFT.deploy();
  })
// ...

On line 5, we set the timeout to 50 seconds. The default value is 20 seconds, but if we are going to test our contracts on the testnet, this can sometimes take longer.

Before every test (line 12), we deploy our NFT contract and create three accounts, the owner, acc1, and acc2. A Signer in Ethers.js represents an account that can sign transactions.

Next, we test if the contract has been deployed correctly to the owner's address:

// ...
  it("Should set the right owner", async function () {
    expect(await myNFT.owner()).to.equal(owner.address);
  });
// ...

Now we check if the owner can mint a new token:

// ...
  it("Should mint one NFT", async function() {
    expect(await myNFT.balanceOf(acc1.address)).to.equal(0);
    
    const tokenURI = "https://example.com/1" // line 28
    const tx = await myNFT.connect(owner).safeMint(acc1.address, tokenURI);
    await tx.wait(); // line 30

    expect(await myNFT.balanceOf(acc1.address)).to.equal(1);
  })
// ...

First, we make sure that the first account has none of our tokens. Then we set the tokenURI (line 28) and mint the NFT with the owner account to acc1 with the tokenURI as metadata.

We wait until the transaction goes through (line 30) before checking if the acc1 now has 1 NFT.

Finally, we check if the right tokenURI has been set:

// ...
  it("Should set the correct tokenURI", async function() {
    const tokenURI_1 = "https://example.com/1"
    const tokenURI_2 = "https://example.com/2"

    const tx1 = await myNFT.connect(owner).safeMint(acc1.address, tokenURI_1);
    await tx1.wait();
    const tx2 = await myNFT.connect(owner).safeMint(acc2.address, tokenURI_2);
    await tx2.wait();

    expect(await myNFT.tokenURI(0)).to.equal(tokenURI_1);
    expect(await myNFT.tokenURI(1)).to.equal(tokenURI_2);
  })
});

Here we set the tokenURI for the first token to tokenURI_1 and the second token to tokenURI_2, wait until the transactions go through, and check if the tokenURI for the first token is tokenURI_1 and the tokenURI for the second token is tokenURI_2.

The test is now complete; you can find the final testing file here.

Additionally, you could test many more critical functions, like transferFrom or enumerable. But we want to keep this tutorial concise.

Now we can run the tests on the Hardhat network:

npx hardhat test

And we test it on the Alfajores network:

npx hardhat test --network alfajores

7. Deploying the NFT Contract

In the last section of this tutorial, we will create a script that will deploy the NFT contract to the owner account.

Delete the scripts/sample-script.js file:

rm scripts/sample-script.js

Create a new scripts/deploy.js file with the following code:

const hre = require("hardhat");

async function main() {
  const MyNFT = await hre.ethers.getContractFactory("MyNFT");
  const myNFT = await MyNFT.deploy();

  await myNFT.deployed();

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

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

This code is the same as the deploy script we created in the previous section, except for a different contract name.

Finally, we can run the script:

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

If everything went well, you should see something like this:

MyNFT deployed to: 0xcAa4101e01dC69EebF3C2EbEaA311af03bc3242D

As described in the previous section, you can now check your contract in the Celo Blockchain Explorer. You can also call your functions in the console to mint an NFT and transfer it to another account, for example.

Remember that you will need to provide some metadata as described in the Intro to NFT Contract Development learning module.

Great job! You have now completed the NFT tutorial. You can now continue to the next learning module, which will teach you how to Build an NFT Minter Dapp with React.

Select a repo