DEV Community

fangjun
fangjun

Posted on • Edited on

A Concise Hardhat Tutorial: Part 3 - ERC721 NFT

This concise hardhat tutorial has 3 sections and this is section 3.

1 . Installation and sample project
2 . Write ERC20 token with OpenZeppelin
3 . Write ERC72 NFT token with on-chain SVG image

Hardhat is an Ethereum development tool suite to compile, unit test, debug and deploy smart contracts.


3. Write a Loot-like NFT Token with SVG image on-chain

In this section, we will write a smart contract for an ERC721 NFT Token. Instead of storing metadata(image and properties) on your server or IPFS, we will store SVG images on-chain like the Loot project. SVG image format is supported by Opensea's storefront and you can view the NFT in it after the contract is deployed to ethereum blockchain mainnet, testnet or Polygon PoS chain.

If you want to know more about metadata, OpenSea provides a good explanation in its tutorial: https://docs.opensea.io/docs/metadata-standards . Please note that if you want your NFT token smart contract to work with Opensea marketplace correctly, you'll need to make it ownable and add proxyRegistryAddress property. Find out more in Opensea documents.

In this tutorial named "A Concise hardhat Tutorial", let's focus on the usage of Hardhat.

In step 2-4, we will compile, test, deploy. Then we will add metadata which has an image in SVG format.


Step 1: Install OpenZeppelin Contracts

If OpenZeppelin is not installed yet, install it by running:

yarn add @openzeppelin/contracts
Enter fullscreen mode Exit fullscreen mode

Step 2: Write an ERC721 NFT Token with OpenZeppelin

We inherit an ERC721 contract from OpenZeppelin ERC721 token contract.

Some explanations about our ERC721 NFT contract:

  • TokenID starts at 1 and auto-increments by 1.
  • Everyone can mint a NFT token by calling mintTo(to) with Token ID.

Metadata will be added in the following steps.

// contracts/BadgeToken.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

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

contract BadgeToken is ERC721 {
    uint256 private _currentTokenId = 0;//Token ID here will start from 1

    constructor(
        string memory _name,
        string memory _symbol
    ) ERC721(_name, _symbol) {
    }

    /**
     * @dev Mints a token to an address with a tokenURI.
     * @param _to address of the future owner of the token
     */
    function mintTo(address _to) public {
        uint256 newTokenId = _getNextTokenId();
        _mint(_to, newTokenId);
        _incrementTokenId();
    }

    /**
     * @dev calculates the next token ID based on value of _currentTokenId
     * @return uint256 for the next token ID
     */
    function _getNextTokenId() private view returns (uint256) {
        return _currentTokenId+1;
    }

    /**
     * @dev increments the value of _currentTokenId
     */
    function _incrementTokenId() private {
        _currentTokenId++;
    }
}
Enter fullscreen mode Exit fullscreen mode

Compile by running:

yarn hardhat compile
Enter fullscreen mode Exit fullscreen mode

Step 3: Write the deploy script

We Write a deploy script scripts/deploy_BadgeToken.ts like this:

// scripts/deploy_BadgeToken.ts
import { ethers } from "hardhat";

async function main() {
  const BadgeToken = await ethers.getContractFactory("BadgeToken");
  console.log('Deploying BadgeToken ERC721 token...');
  const token = await BadgeToken.deploy('BadgeToken','Badge');

  await token.deployed();

  console.log("BadgeToken deployed to:", token.address);
}

main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});
Enter fullscreen mode Exit fullscreen mode

Run the deploy script:

yarn hardhat run scripts/deploy_BadgeToken.ts
Enter fullscreen mode Exit fullscreen mode

Step 4: Write Unit Test

Again, we write a simple unit test for our BadgeToken ERC721 token:

  • Check name and symbol
  • Mint 2 NFTs

The unit test script is test/BadgeToken-test.js:

// We import Chai to use its asserting functions here.
import { loadFixture } from "@nomicfoundation/hardhat-network-helpers";
import { expect } from "chai";
import { ethers } from "hardhat";

describe("BadgeToken", function () {

    async function deployBadgeTokenFixture() {  
      // Contracts are deployed using the first signer/account by default
      const [owner, otherAccount] = await ethers.getSigners();

      const BadgeToken = await ethers.getContractFactory("BadgeToken");
      const token = await BadgeToken.deploy('BadgeToken','Badge');

      return { token, owner, otherAccount };
    }

    describe("Deployment", function () {
      it("Should has the correct name and symbol", async function () {
        const { token, owner } = await loadFixture(deployBadgeTokenFixture);
        const total = await token.balanceOf(owner.address);
        expect(total).to.equal(0);
        expect(await token.name()).to.equal('BadgeToken');
        expect(await token.symbol()).to.equal('Badge');
      });
    });

    describe("Mint NFT", function () {
      it("Should mint a token with token ID 1 & 2 to account1", async function () {
        const { token, owner, otherAccount } = await loadFixture(deployBadgeTokenFixture);

        const address1=otherAccount.address;
        await token.mintTo(address1);
        expect(await token.ownerOf(1)).to.equal(address1);

        await token.mintTo(address1);
        expect(await token.ownerOf(2)).to.equal(address1);

        expect(await token.balanceOf(address1)).to.equal(2);      
      });  
    });
});

Enter fullscreen mode Exit fullscreen mode

Run unit test:

yarn hardhat test test/BadgeToken.test.ts
Enter fullscreen mode Exit fullscreen mode

Output:

  GLDToken
    Deployment
      ✔ Should has the correct name and symbol (731ms)
    Mint NFT
      ✔ Should mint a token with token ID 1 & 2 to account1

  2 passing (758ms)
✨  Done in 3.30s.
Enter fullscreen mode Exit fullscreen mode

Step 5: Add metadata: name, description and svg image

Note: Step 5/6 is an advanced topic. You can also refer to my other tutorial: Web3 Tutorial: Build an NFT marketplace DApp like OpenSea

We need to do base64 encoding in Solidity. The SVG format image is encodes with base64 and then included in the metadata. Metadata is also encoded with base64.

We use the base64.sol library to conduct base64 encode adapted from the Loot project. The original base64 library by Brecht Devos is: https://github.com/Brechtpd/base64 . 0xMoJo7 wrote a on-chain SVG generation tutorial on dev.to, you can also refer to this link: https://dev.to/0xmojo7/on-chain-svg-generation-part-1-2678 .

Metadata is returned by ERC721 API function tokenURI. Make some changes to the contract:

  • Import base64.sol and OpenZeppelin Utils Strings.sol.
  • Implement function tokenURI(tokenId). We create a SVG format image with black background and white tokenId. Name is set as Badge+tokenId ("Badge #1" for example). Description is set as "A concise Hardhat tutorial Badge NFT with on-chain SVG images like look."
// contracts/BadgeToken.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/utils/Strings.sol";
import "./base64.sol";

contract BadgeToken is ERC721 {
    uint256 private _currentTokenId = 0;//Token ID here will start from 1

    constructor(
        string memory _name,
        string memory _symbol
    ) ERC721(_name, _symbol) {
    }

    /**
     * @dev Mints a token to an address with a tokenURI.
     * @param _to address of the future owner of the token
     */
    function mintTo(address _to) public {
        uint256 newTokenId = _getNextTokenId();
        _mint(_to, newTokenId);
        _incrementTokenId();
    }

    /**
     * @dev calculates the next token ID based on value of _currentTokenId
     * @return uint256 for the next token ID
     */
    function _getNextTokenId() private view returns (uint256) {
        return _currentTokenId+1;
    }

    /**
     * @dev increments the value of _currentTokenId
     */
    function _incrementTokenId() private {
        _currentTokenId++;
    }

    /**
     * @dev return tokenURI, image SVG data in it.
     */

    function tokenURI(uint256 tokenId) override public pure returns (string memory) {
        string[3] memory parts;
        parts[0] = '<svg xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMinYMin meet" viewBox="0 0 350 350"><style>.base { fill: white; font-family: serif; font-size: 14px; }</style><rect width="100%" height="100%" fill="black" /><text x="10" y="20" class="base">';

        parts[1] = Strings.toString(tokenId);

        parts[2] = '</text></svg>';

        string memory output = string(abi.encodePacked(parts[0], parts[1], parts[2]));

        string memory json = Base64.encode(bytes(string(abi.encodePacked('{"name": "Badge #', Strings.toString(tokenId), '", "description": "A concise Hardhat tutorial Badge NFT with on-chain SVG images like look.", "image": "data:image/svg+xml;base64,', Base64.encode(bytes(output)), '"}'))));
        output = string(abi.encodePacked('data:application/json;base64,', json));

        return output;
    }
}
Enter fullscreen mode Exit fullscreen mode

Compile, test and deploy:

yarn hardhat compile
yarn hardhat test
yarn hardhat run scripts/deploy_BadgeToken.ts
Enter fullscreen mode Exit fullscreen mode

Step 6: Play with the NFT contract

Let's run a stand-alone hardhat network and deploy our contract to it. Now we can play with the NFT contract from hardhat console.

In another terminal, run command line in tutorial directory:

yarn hardhat node
Enter fullscreen mode Exit fullscreen mode

Back to the current terminal, run deployment:

yarn hardhat run scripts/deploy_BadgeToken.ts --network localhost 
//Output: Deploying BadgeToken ERC721 token...
// BadgeToken deployed to: 0x5FbDB2315678afecb367f032d93F642f64180aa3
Enter fullscreen mode Exit fullscreen mode

Open console:

yarn hardhat console --network localhost
Enter fullscreen mode Exit fullscreen mode

In the terminal, run:

const address = '0x5FbDB2315678afecb367f032d93F642f64180aa3';
const token721 = await ethers.getContractAt("BadgeToken", address);

const accounts = await hre.ethers.getSigners();
owner = accounts[0].address;
toAddress = accounts[1].address;

await token721.symbol()
//'Badge'
Enter fullscreen mode Exit fullscreen mode

Mint NFT and view it in online tools.

//mint NFT tokenId 1
await token721.mintTo(toAddress)

//mint NFT tokenId 2
await token721.mintTo(toAddress)

//mint NFT tokenId 3
await token721.mintTo(toAddress)

await token721.balanceOf(toAddress)
//3
Enter fullscreen mode Exit fullscreen mode

Get the metadata by calling tokenURI:

await token721.tokenURI(3)
//Output:
//'data:application/json;base64,eyJuYW1lIjogIkJhZGdlICMzIiwgImRlc2NyaXB0aW9uIjogIkEgY29uY2lzZSBIYXJkaGF0IHR1dG9yaWFsIEJhZGdlIE5GVCB3aXRoIG9uLWNoYWluIFNWRyBpbWFnZXMgbGlrZSBsb29rLiIsICJpbWFnZSI6ICJkYXRhOmltYWdlL3N2Zyt4bWw7YmFzZTY0LFBITjJaeUI0Yld4dWN6MGlhSFIwY0RvdkwzZDNkeTUzTXk1dmNtY3ZNakF3TUM5emRtY2lJSEJ5WlhObGNuWmxRWE53WldOMFVtRjBhVzg5SW5oTmFXNVpUV2x1SUcxbFpYUWlJSFpwWlhkQ2IzZzlJakFnTUNBek5UQWdNelV3SWo0OGMzUjViR1UrTG1KaGMyVWdleUJtYVd4c09pQjNhR2wwWlRzZ1ptOXVkQzFtWVcxcGJIazZJSE5sY21sbU95Qm1iMjUwTFhOcGVtVTZJREUwY0hnN0lIMDhMM04wZVd4bFBqeHlaV04wSUhkcFpIUm9QU0l4TURBbElpQm9aV2xuYUhROUlqRXdNQ1VpSUdacGJHdzlJbUpzWVdOcklpQXZQangwWlhoMElIZzlJakV3SWlCNVBTSXlNQ0lnWTJ4aGMzTTlJbUpoYzJVaVBqTThMM1JsZUhRK1BDOXpkbWMrIn0='
Enter fullscreen mode Exit fullscreen mode

We will use the online base64 decoder https://www.base64decode.org/ to get the original data.

We need to conduct two decode processes: first, decode the output data; second, decode the SVG image data.

In the first decode process, we get the following result. You can read the name and description in it.

{"name": "Badge #3", "description": "A concise Hardhat tutorial Badge NFT with on-chain SVG images like look.", "image": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHByZXNlcnZlQXNwZWN0UmF0aW89InhNaW5ZTWluIG1lZXQiIHZpZXdCb3g9IjAgMCAzNTAgMzUwIj48c3R5bGU+LmJhc2UgeyBmaWxsOiB3aGl0ZTsgZm9udC1mYW1pbHk6IHNlcmlmOyBmb250LXNpemU6IDE0cHg7IH08L3N0eWxlPjxyZWN0IHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjEwMCUiIGZpbGw9ImJsYWNrIiAvPjx0ZXh0IHg9IjEwIiB5PSIyMCIgY2xhc3M9ImJhc2UiPjM8L3RleHQ+PC9zdmc+"}
Enter fullscreen mode Exit fullscreen mode

The SVG data is still in base64. Let's decode it online:

<svg xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMinYMin meet" viewBox="0 0 350 350"><style>.base { fill: white; font-family: serif; font-size: 14px; }</style><rect width="100%" height="100%" fill="black" /><text x="10" y="20" class="base">3</text></svg>
Enter fullscreen mode Exit fullscreen mode

You can take a look at the image in online SVG viewers such as https://www.svgviewer.dev/.

NFT SVG image

The image is a SVG image with tokenId in it.

As you can see we successfully write an ERC72 token smart contract with metadata and on-chain SVG image.


Note for deploying this NFT contract to public chain

You may try to deploy this NFT contract to public chain such as Polygon Mainnet and view it on marketplaces like Opensea. You will need an Alchemy Account and APIKEY to deploy contract to Polygon as well as $MATIC asset for paying gas.

Here is an outline:

  1. Configure the network part in hardhat.config.ts for Polygon Mainnet. (Get RPC URL in your Alchemy dashboard. Use a new account with just a small amount $MATIC for this test purpose and DO NOT use your main account for assets.)
  2. Deploy the BadgeToken ERC721 contract to Polygon Mainnet
  3. Play with your ERC721 contract using Hardhat console: mint, transfer and etc.
  4. View and transfer NFT using marketplaces like Opensea.

Alchemy also provides some handy NFT APIs to pull NFT metadata. When your NFT contract is deployed to its supported network such as Polygon, you can try these APIs.


This is the end of "A concise hardhat tutorial" with 3 sections.

1 . Installation and sample project
2 . Write ERC20 token with OpenZeppelin
3 . Write ERC72 NFT token with on-chain SVG image

If you feel this tutorial useful, follow me at Twitter: @fjun99. DM is open.

Top comments (3)

Collapse
 
sylwesterdigital profile image
Sylwester Mielniczuk

Hi!, Great Tutorial. However I stuck before minting. Please look at the log below.

➜  erc721 git:(main) ✗ npx hardhat compile                        
Compiled 11 Solidity files successfully
➜  erc721 git:(main) ✗ npx hardhat run scripts/Endlessia-deploy.js                     
Deploying Endlessia ERC721 token...
Endlessia deployed to: 0x5FbDB2315678afecb367f032d93F642f64180aa3
➜  erc721 git:(main) ✗ npx hardhat console --network localhost    
Welcome to Node.js v18.8.0.
Type ".help" for more information.
> const address = '0x5FbDB2315678afecb367f032d93F642f64180aa3';
undefined
> const token721 = await ethers.getContractAt("Endlessia", address);
Uncaught:
NomicLabsHardhatPluginError: 0x5FbDB2315678afecb367f032d93F642f64180aa3 is not a contract account.
    at getContractAt (/Users/malefist/Documents/WORK/wwf-smartcontract/erc721/node_modules/@nomiclabs/hardhat-ethers/src/internal/helpers.ts:307:11)
    at processTicksAndRejections (node:internal/process/task_queues:95:5)
    at REPL2:1:47
    at node:repl:619:29
> const accounts = await hre.ethers.getSigners();
undefined
> owner = accounts[0].address;
'0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266'
> toAddress = accounts[1].address;
'0x70997970C51812dc3A010C7d01b50e0d17dc79C8'
> await token721.symbol()
Uncaught TypeError: Cannot read properties of undefined (reading 'symbol')
    at REPL6:1:48
> await token721.mintTo(toAddress)
Uncaught TypeError: Cannot read properties of undefined (reading 'mintTo')
    at REPL7:1:48
> 
Enter fullscreen mode Exit fullscreen mode

Did I missed something? The deployment address is not a contract account. What does that mean?

Collapse
 
garyrob profile image
Gary Robinson

I don't understand why, in tokenURI(), parts is declared as

string[17] memory parts

when only the first 3 elements are accessed. Why isn't it string[3]??

Collapse
 
yakult profile image
fangjun

Should be string[3].

I take it from loot's contract for quick and dirty implementation. And forget to change the details.

Thanks.