Create, deploy and mint smart contract (ERC-721) with NodeJS + Hardhat + Walletconnect + Web3modal

igaponov

Igor

Posted on September 30, 2022

Create, deploy and mint smart contract (ERC-721) with NodeJS + Hardhat + Walletconnect + Web3modal

We will create from scratch ERC-721 smartcontract, deploy it to Ethereum Goerli network using Hardhat + Solidity. For minting we will use NextJs + WalletConnect + Web3Modal

Before we start, I would like to mention, that you can use any network you want. With the help of this article, you can deploy to Mainnet also.


Demo: https://gapon2401.github.io/erc721-mint/
Smartcontract repo (erc721): https://github.com/gapon2401/erc721
Mint page repo (erc721-mint): https://github.com/gapon2401/erc721-mint
Smartcontract: Etherscan Goerli link

We need to go through the following steps to reach our goal:

  1. Creating NFT (ERC-721) smartcontract
  2. Configure hardhat.config.ts andΒ .env
  3. Create deploy function
  4. Create tests
  5. Gas price for deployment and minting the smartcontract
  6. Creating images and metadata for NFT
  7. Deploy and verify smartcontract
  8. Create demo website for minting
  9. (optional) Deep dive and customize

Step 1. Creating NFT (ERC-721) smartcontract

Create new project and install https://hardhat.org - Ethereum development environment. Let's call this project erc721.

In command line run

npx hardhat
Enter fullscreen mode Exit fullscreen mode

Select Create a Typescript project.

In order to continue you will be asked to install some dependencies. The text may vary on the hardhat version.

Hardhat installation

I will run the following, because I will use yarn:

yarn add -D hardhat@^2.10.2 @nomicfoundation/hardhat-toolbox@^1.0.1
Enter fullscreen mode Exit fullscreen mode

We will use https://www.openzeppelin.com/ template for ERC-721 smartcontract, where all necessary functions have been implemented. You can take a look on it: https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/token/ERC721/ERC721.sol

Install openzeppelin packages and dotenv:

yarn add -D @openzeppelin/contracts dotenv
Enter fullscreen mode Exit fullscreen mode

Remove everything from the folder contracts and create a file ERC721Contract.sol with the contents:

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

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

contract ERC721Contract is ERC721, ERC721URIStorage, Ownable {
    using Counters for Counters.Counter;
    Counters.Counter private _tokenIds;

    // Mint price - 0.1 ETH
    uint256 public constant PRICE = 100000000000000000;

    string private baseURI;

    constructor(string memory _baseTokenURI, string memory _name, string memory _symbol)
    ERC721(_name, _symbol) {
        baseURI = _baseTokenURI;
    }

    /**
     * @notice Contract might receive/hold ETH as part of the maintenance process.
     */
    receive() external payable {}

    /**
     * @notice Mint only for owner
     */
    function mintByOwner(address to, string calldata tokenUri) external onlyOwner
    {
        uint256 newItemId = _tokenIds.current();
        _tokenIds.increment();

        _safeMint(to, newItemId);
        _setTokenURI(newItemId, tokenUri);
    }


    /**
     * @notice Public mint
     */
    function mint() external payable
    {
        require(PRICE <= msg.value, "INVALID_PRICE");

        uint256 newItemId = _tokenIds.current();
        _tokenIds.increment();
        _safeMint(msg.sender, newItemId);

        // Return money, if sender have sent more than 0.1 ETH
        if (msg.value > PRICE) {
            Address.sendValue(payable(msg.sender), msg.value - PRICE);
        }
    }

    /**
     * @notice Read the base token URI
     */
    function _baseURI() internal view override returns (string memory) {
        return baseURI;
    }

    /**
     * @notice Update the base token URI
     */
    function setBaseURI(string calldata uri) external onlyOwner {
        baseURI = uri;
    }

    /**
     * @notice Allow withdrawing funds
     */
    function withdraw() external onlyOwner {
        uint256 balance = address(this).balance;
        Address.sendValue(payable(msg.sender), balance);
    }

    // The following functions are overrides required by Solidity.
    function _burn(uint256 tokenId) internal override(ERC721, ERC721URIStorage) {
        super._burn(tokenId);
    }

    /**
     * @notice Add json extension to all token URI's
     */
    function tokenURI(uint256 tokenId) public view override(ERC721, ERC721URIStorage) returns (string memory)
    {
        return string(abi.encodePacked(super.tokenURI(tokenId), '.json'));
    }
}
Enter fullscreen mode Exit fullscreen mode

If you have any difficulties with the code above, feel free to read more about the Solidity. Here is the summary:

  • constructor - on deployment we will set the base URI for all tokens, name and a symbol to the token collection.
  • mintByOwner - is our function, which will create new NFT's for us. Only owner of the contract can call it.
  • mint - public mint.
  • setBaseURI - owner of the contract can change the base URI for tokens
  • withdraw - owner can call this function to transfer money from the contract to it's own wallet address

All other necessary functions are inhereted in this contract from openzeppelin.

The price of the tokens will be 0.1 ETH:

uint256 public constant PRICE = 100000000000000000;
Enter fullscreen mode Exit fullscreen mode

Step 2. Configure hardhat.config.ts and .env

Install the following dependencies in order to escape the errors in developing 😲

yarn add -D ethers @nomiclabs/hardhat-etherscan @typechain/hardhat hardhat-gas-reporter solidity-coverage @types/chai @types/mocha typescript ts-node @nomicfoundation/hardhat-chai-matchers chai @nomicfoundation/hardhat-network-helpers typechain @nomiclabs/hardhat-waffle @typechain/ethers-v5 @nomiclabs/hardhat-ethers
Enter fullscreen mode Exit fullscreen mode

Now configure your hardhat.config.ts:

import * as dotenv from "dotenv";

import { HardhatUserConfig } from "hardhat/config";
import "@nomicfoundation/hardhat-toolbox";

import "./tasks/deploy";

dotenv.config();

const config: HardhatUserConfig = {
  solidity: {
    version: "0.8.14",
    settings: {
      metadata: {
        bytecodeHash: "none",
      },
      optimizer: {
        enabled: true,
        runs: 200,
      },
    },
  },
  networks: {
    goerli: {
      url: process.env.GOERLI_URL,
      accounts:
        process.env.PRIVATE_KEY !== undefined ? [process.env.PRIVATE_KEY] : [],
    },
    mainnet: {
      url: process.env.MAINNET_URL,
      accounts:
        process.env.PRIVATE_KEY !== undefined ? [process.env.PRIVATE_KEY] : [],
    },
  },
  gasReporter: {
    enabled: !!process.env.REPORT_GAS,
  },
  etherscan: {
    apiKey: process.env.ETHERSCAN_API_KEY,
  },
};

export default config;
Enter fullscreen mode Exit fullscreen mode

What do we have here:

  • solidity - we specified the settings and enabled optimization

  • networks - networks, that we are going to use

  • gasReporter - will show us gas units, that we need to spend for deployment or calling the functions

  • etherscan - plugin for integration with Etherscan's contract verification service

The next step is to create .env file in project root. You have to create 5 variables:

Etherscan API key

For instance, infura URL for MAINNET_URL:

Infura mainnet url

Infura URL for GOERLI_URL:

Infura goerli url

Now compile the code with the command:

npx hardhat compile
Enter fullscreen mode Exit fullscreen mode

After that command some new folders will appear: artifacts, cache, typechain-types.


Step 3. Create deploy function

It's time to write deploy function, we will use tasks for it.

Remove folder scripts, we don't need it.

Create the folder tasks and the file deploy.ts inside it:

import { task } from "hardhat/config";

task("deploy").setAction(async function (_, { ethers, run }) {
  console.log("Start deploying");
  try {
    // The path to token metadata
    const baseUri = "https://my-domain.com/collection/metadata/";
    const _name = "My ERC721 name";
    const _symbol = "MyERC721Symbol";
    const ERC721Contract = await ethers.getContractFactory("ERC721Contract");
    const erc721Contract = await ERC721Contract.deploy(baseUri, _name, _symbol);

    await erc721Contract.deployed();

    console.log("Contract deployed to address:", erc721Contract.address);
  } catch (error) {
    console.error(error);
    process.exit(1);
  }
});
Enter fullscreen mode Exit fullscreen mode

In this task we get smartcontract and deploy it. After the deployment in console we will see the address of the contract.

We will come back to that function later.


Step 4. Create tests

We will write the tests for every exteral/public function.

Clear the folder test and create index.ts inside it with the contents:

import { ethers } from "hardhat";
import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/dist/src/signer-with-address";
import { ERC721Contract } from "../typechain-types";
import { expect } from "chai";

interface Signers {
  admin: SignerWithAddress;
  user: SignerWithAddress;
}

describe("ERC721Contract", function () {
  before(async function () {
    this.signers = {} as Signers;
    const signers: SignerWithAddress[] = await ethers.getSigners();
    this.signers.admin = signers[0];
    this.signers.user = signers[1];

    this.baseUri = "https://my-domain.com/collection/metadata/";
  });

  beforeEach(async function () {
    const _name = "My ERC721 name";
    const _symbol = "MyERC721Symbol";

    const ERC721ContractFactory = await ethers.getContractFactory(
      "ERC721Contract"
    );

    this.contract = <ERC721Contract>(
      await ERC721ContractFactory.deploy(this.baseUri, _name, _symbol)
    );

    await this.contract.deployed();
  });

  it("Mint 5 tokens by owner", async function () {
    await this.contract.mintByOwner(
      this.signers.user.address,
      "link_to_token_json1"
    );
    await this.contract.mintByOwner(
      this.signers.user.address,
      "link_to_token_json2"
    );
    await this.contract.mintByOwner(
      this.signers.user.address,
      "link_to_token_json3"
    );
    await this.contract.mintByOwner(
      this.signers.user.address,
      "link_to_token_json4"
    );
    await this.contract.mintByOwner(
      this.signers.user.address,
      "link_to_token_json5"
    );

    // Check balance of the user
    expect(await this.contract.balanceOf(this.signers.user.address)).to.equal(
      5
    );
  });

  it("Revert, if user is trying to use mintByOwner", async function () {
    expect(
      this.contract
        .connect(this.signers.user)
        .mintByOwner(this.signers.user.address, "link_to_token_json1")
    ).to.be.reverted;
  });

  it("Mint token by user", async function () {
    await this.contract
      .connect(this.signers.user)
      .mint({ value: ethers.utils.parseEther("0.1") });

    expect(await this.contract.balanceOf(this.signers.user.address)).to.equal(
      1
    );
  });

  it("Should revert INVALID_PRICE", async function () {
    expect(
      this.contract
        .connect(this.signers.user)
        .mint({ value: ethers.utils.parseEther("0.05") })
    ).to.be.revertedWith("INVALID_PRICE");
  });

  it("Should return back overpaid eth to sender", async function () {
    const price = ethers.utils.parseEther("0.1");
    expect(
      this.contract
        .connect(this.signers.user)
        .mint({ value: ethers.utils.parseEther("1.5") })
    ).to.changeEtherBalances(
      [this.signers.user, this.contract],
      [price.mul(-1), price]
    );
  });

  it("Check receiving eth to contract and withdraw", async function () {
    const value = ethers.utils.parseEther("1.5");
    await this.signers.user.sendTransaction({
      to: this.contract.address,
      value,
    });

    expect(this.contract.withdraw()).to.changeEtherBalances(
      [this.signers.admin, this.contract],
      [value, value.mul(-1)]
    );
  });

  it("Set base URI", async function () {
    const value = ethers.utils.parseEther("0.1");

    // Mint 2 tokens
    await this.contract.connect(this.signers.user).mint({ value });
    await this.contract.connect(this.signers.user).mint({ value });

    // Check token URI's
    expect(await this.contract.tokenURI(0)).to.be.equal(
      this.baseUri + "0.json"
    );
    expect(await this.contract.tokenURI(1)).to.be.equal(
      this.baseUri + "1.json"
    );

    // Change base URI
    const newBaseUri = "https://new_domain_or_ipfs/";
    await this.contract.setBaseURI(newBaseUri);

    // Check new token URI's
    expect(await this.contract.tokenURI(0)).to.be.equal(newBaseUri + "0.json");
    expect(await this.contract.tokenURI(1)).to.be.equal(newBaseUri + "1.json");
  });

  it("Check token URI", async function () {
    const value = ethers.utils.parseEther("0.1");

    // Mint token by user
    await this.contract.connect(this.signers.user).mint({ value });
    // Check token URI
    expect(await this.contract.tokenURI(0)).to.be.equal(
      this.baseUri + "0.json"
    );

    // Mint token by owner with specified URI
    await this.contract.mintByOwner(this.signers.user.address, "link_to_uri");

    // Check token URI
    expect(await this.contract.tokenURI(1)).to.be.equal(
      this.baseUri + "link_to_uri.json"
    );
  });
});
Enter fullscreen mode Exit fullscreen mode

Run npx hardhat test.

You should get similar to that:

npx harhdat test

All tests passed successfully!


Step 5. Gas price for deployment and minting the smartcontract

For determine the price of deployment and minting functions we have used module hardhat-gas-reporter. It was enabled by default in hardhat.config.ts, that's why after all tests were passed, we got the table:

Gas units table

In the table we can see the gas consumption for methods and deployment.

Deployment gas units

This is the units of gas we need to deploy the contract.

How much it will cost in ETH?

Use the formula:

(gas units) * (gas price per unit) = gas fee in gwei
Enter fullscreen mode Exit fullscreen mode

In this formula we don't know the amount of gas price per unit.

You can use https://ethgasstation.info/ or any other website to find this information. At the moment of writing this article, gas price is 17.

Gas price per unit

The value can change greatly depending on the time of day. It may be 2 or 10 or 50 πŸ™ƒ. Be careful.

So, the average cost will be:

Deployment = 1 697 092 * 17 = 28 850 564 gwei = 0,028850564 ETH
mint = 88 753 * 17 = 1 508 801 gwei = 0,001508801 ETH
mintByOwner = 91 060 * 17 = 1 548 020 gwei = 0,001548020 ETH
setBaseURI = 32 153 * 17 = 546 601 gwei = 0,000546601 ETH
withdraw = 30 421 * 17 = 517 157 gwei = 0,000517157 ETH
Enter fullscreen mode Exit fullscreen mode

Now the ETH/USD price is $1 592, it means that deployment to mainnet will cost about $46 and calling a mint function will cost about $2.4.


Step 6. Creating images and metadata for NFT

Images and metadata for NFTs can be stored on your server or in IPFS.

We will cover the first case. Assume, that you have a domain

https://my-domain.com
Enter fullscreen mode Exit fullscreen mode

All NFT images will store here:

https://my-domain.com/collection/assets/
Enter fullscreen mode Exit fullscreen mode

All metadata here:

https://my-domain.com/collection/metadata/
Enter fullscreen mode Exit fullscreen mode

So, the base URI for our smartcontract will be

https://my-domain.com/collection/metadata/
Enter fullscreen mode Exit fullscreen mode

In our smartcontract by default all token URI's will be counted from 0. Therefore, files for metadata should be like this:

0.json
1.json
....
1234.json
Enter fullscreen mode Exit fullscreen mode

If you will use mintByOwner function, then you can change the name of the file for specific NFT, and it can be like this: custom.json

Upload NFT images on your server. They should be available by links:

https://my-domain.com/collection/assets/0.png
https://my-domain.com/collection/assets/1.png
https://my-domain.com/collection/assets/2.png
Enter fullscreen mode Exit fullscreen mode

Now create metadata for NFT's.

Here is an example of one file 0.json:

{
    "name": "Test NFT #0",
    "image": "https://my-domain.com/collection/assets/0.png"
}
Enter fullscreen mode Exit fullscreen mode

Pay attention, that in image we have defined the link to the image, which has been uploaded to the server.

Read more about opensea metadata and available values in it.

Upload your metadata files, so they should be available by links:

https://my-domain.com/collection/metadata/0.json
https://my-domain.com/collection/metadata/1.json
https://my-domain.com/collection/metadata/2.json
Enter fullscreen mode Exit fullscreen mode

Step 7. Deploy and verify smartcontract

Everything is ready for deployment.

Take a look on our tasks/deploy.ts, we've specified baseUri as

https://my-domain.com/collection/metadata/
Enter fullscreen mode Exit fullscreen mode

This is because all our metadata is available by this address.

Here you can change the name and the symbol of the collection.

In command line run:

npx hardhat deploy --network goerli
Enter fullscreen mode Exit fullscreen mode

This command will deploy smartcontract to Goerli network. If you want to deploy to mainnet, you can run:

npx hardhat deploy --network mainnet
Enter fullscreen mode Exit fullscreen mode

All these networks were described on hardhat.config.ts file.

After the deployment you will get the following in your console:

Deployment of smartcontract

My smartcontract address is:

0xc191B6505B16EBe5D776fb30EFbfe41A9252023a
Enter fullscreen mode Exit fullscreen mode

From now, it is available on etherscan testnet:

https://goerli.etherscan.io/address/0xc191B6505B16EBe5D776fb30EFbfe41A9252023a
Enter fullscreen mode Exit fullscreen mode

We need to verify our smartcontract, so everyone can check the code.

Wait about 5 minutes and try to verify it. If you get an error while verifying, don't worry, just try to wait more time. In Mainnet I was waiting for 10–15 minutes.

In general verify function looks like this:

npx hardhat verify --network <YOUR_NETWORK> <DEPLOYED_CONTRACT_ADDRESS> <arg1> <arg2> <argn>
Enter fullscreen mode Exit fullscreen mode

Let's try to determine, what to specify in our verify function:

  • <YOUR_NETWORK> - goerli

  • <DEPLOYED_CONTRACT_ADDRESS> will be my contract address

0xc191B6505B16EBe5D776fb30EFbfe41A9252023a
Enter fullscreen mode Exit fullscreen mode
  • <arg1> - this is our base URI. You will find it in tasks/deploy.ts
"https://my-domain.com/collection/metadata/"
Enter fullscreen mode Exit fullscreen mode
  • <arg2> - name of the token collection, that was specified in deployment:
"My ERC721 name"
Enter fullscreen mode Exit fullscreen mode
  • <arg3> - symbol of the token collection, that was specified in deployment:
"MyERC721Symbol"
Enter fullscreen mode Exit fullscreen mode

Arguments should be the same, that were in tasks/deploy.ts!!!

So, in command line I will run:

npx hardhat verify --network goerli 0xc191B6505B16EBe5D776fb30EFbfe41A9252023a "https://my-domain.com/collection/metadata/" "My ERC721 name" "MyERC721Symbol"
Enter fullscreen mode Exit fullscreen mode

The result should be the next:

Verified smartcontract

On this page you can check the code of deployed smartcontract.

πŸ₯³We have finished with the smartcontract. Here is the repo, where you can find all the code.


Step 8. Create demo website for minting

This will be our second project erc721-mint.

For demo website I will use NextJS.

To speed up the development clone my repository: https://github.com/gapon2401/erc721-mint

Install all dependencies:

yarn install
Enter fullscreen mode Exit fullscreen mode

Open file .env and fill in the variables:

  • NEXT_PUBLIC_SMARTCONTRACT_ADDRESS - address of your smartcontract from the paragraph Deploy and verify smartcontract.

  • NEXT_PUBLIC_SMARTCONTRACT_NETWORK - the network, where your smartcontract was deployed. We need this to switch the network. Specify goerli, because we have deployed our smartcontact there.

  • NEXT_PUBLIC_SMARTCONTRACT_INFURA_URL - network endpoint. You can use https://infura.io to get the url.

Go to your dashboard, select your project and copy Api Key:

Infura API key

In our first smartcontract project erc721 open the file:

artifacts/contracts/ERC721Contract.sol/ERC721Contract.json
Enter fullscreen mode Exit fullscreen mode

Copy abi:

artifacts/contracts/ERC721Contract.sol/ERC721Contract.json- abi

In current project erc721-mint open the file:

src/components/smartcontract.ts
Enter fullscreen mode Exit fullscreen mode

Replace the abi value.

Everything is done! πŸŽ‰ You can run the project yarn dev and try to use it!

In the next section I will cover the main components.


Step 9 (optional). Deep dive and customize

Take a look on main components:

1) src/components/smartcontract.ts

Contains information about the smartcontract. It's better to dynamically load this file from the server, because we want our web page loads as soon as possible. In this demo mint form appears after the page loads.

2) src/components/Web3Provider.tsx

This component communicates with blockchain. There are some functions and variables stored in React Context:

  • connected - has user connected the wallet or not

  • isConnecting- the state of reconnecting

  • connect() - function for connecting to network

  • reconnect() – function for reconnecting. Cached provider will be used, that was selected by user at the last time

  • disconnect() – function for disconnecting

  • network – selected by user network: goerli, mainnet etc

  • provider – provider for communicating with the wallet: metamask, ledger, tokenary, etc

  • signer- the owner of the wallet

  • chainId – ID of the selected network

  • accountId – your wallet address

  • error - error message for web3Modal

3) src/components/MintForm.tsx

This component displays form for minting.

Function mint() is called after the user clicks on the mint button.

Now look at the code:

const transactionResponse = await Contract.mint({
    value: ethers.utils.parseEther('0.1'),
})
Enter fullscreen mode Exit fullscreen mode

We are calling mint function from the smartcontract and send 0.1 ETH to it, because this function requires exactly that value.

What if we want to call mintByOwner?

We can do it like this:

const transactionResponse = await Contract.mintByOwner(
    '0x01234566', 'token_uri'
)
Enter fullscreen mode Exit fullscreen mode

But pay attention, that this function can be called only by the owner of the contract. Why? We have used onlyOwner modifier in smartcontract:

function mintByOwner(address to, string calldata tokenUri) external onlyOwner
Enter fullscreen mode Exit fullscreen mode

A few more words about that function.

0x01234566 - this is the address, for which you want to mint token. Make sure to write full address.

token_uri - this is custom token URI. In our deployed smartcontract the full address to token metadata looks like:

${baseUri + token_uri}.json
Enter fullscreen mode Exit fullscreen mode

Remember, we have specified baseUri:

https://my-domain.com/collection/metadata/

Enter fullscreen mode Exit fullscreen mode

So, the full address to metadata with token URI equals token_url will be:

https://my-domain.com/collection/metadata/token_url.json
Enter fullscreen mode Exit fullscreen mode

Also, we don't need to pay for this transaction, except gas price.

Function prepareNetwork().

On first connection this function can propose to change the network. In this function are used only goerli and mainnet networks. You can change it, if you need, here:

chainId: smartContract.network === 'goerli' ? '0x5' : '0x1',
Enter fullscreen mode Exit fullscreen mode

Function getBalanceOf.

This function checks is it enough money on the wallet to make a transaction.

In the file you may find commented code in two places:

/* reconnect ,*/
Enter fullscreen mode Exit fullscreen mode

and

/* Reconnect to the wallet */
// useEffect(() => {
//   ;(async () => {
//     await reconnect()
//   })()
// }, [reconnect])
Enter fullscreen mode Exit fullscreen mode

Uncomment it to make reconnections every time the page reloads.

Thank you for reading! ❀

πŸ’– πŸ’ͺ πŸ™… 🚩
igaponov
Igor

Posted on September 30, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related