Mock Web3 Project

Introduction

I will be discussing the topic of ERC-721 smart contracts and specifically, the ERC-721A contract. The Ethereum Request for Comments (ERC) 721 is the standard for creating non-fungible tokens (NFTs). As the name suggests, NFTs are unique and cannot be replaced by another token. In contrast, fungible items such as Bitcoin, US dollars, and gold are interchangeable. The ERC-721 was proposed in January 2018 by William Entriken, Dieter Shirley, Jacob Evans, and Nastassia Sachs. Since then, many developers and projects have adopted this standard to create NFTs.

ERC-721A

A notable implementation of the ERC-721 standard is the ERC-721A contract, created by the developer cygaar for the Azuki NFT project. The savings can be observed by comparing the ERC-721A contract to the standard ERC-721 contract from OpenZeppelin. As a reference, a table from cygaar's article has been included to demonstrate the potential savings.

ERC-721A Gas Savings
ERC-721A Gas Savings

Project Overview

As a professional with a keen interest in web3 technology, I have decided to undertake a mock project that incorporates an ERC-721A smart contract, an art engine, and a decentralized application (dApp). The smart contract will feature the following:

  • The ERC-721A standard

  • Libraries from OpenZeppelin including Strings, Ownable, Merkle Proof, and Reentrancy Guard

  • Standard minting, whitelist minting, and airdropping functions

  • Three internal modifiers

  • Minting and metadata control

  • A Merkle Tree for whitelist control

  • The Hardhat framework for compiling, unit testing, and deploying the contract.

The art engine for this project has been developed by HashLips and the Sketchy Labs team. It allows developers to customize the entire NFT collection in a single codebase, including rarity, metadata, and collection generation. I would highly recommend reviewing the GitHub and YouTube channel of HashLips and the Sketchy Labs team. It should be noted that this codebase has been utilized by multiple projects in the industry.

The dApp frontend is planned to be built using React, and it will enable users to mint from the collection. During the whitelist minting phase, users will be required to provide their wallet's Merkle proof. To enhance the user experience, I will implement a mechanism to retrieve the user's wallet from Metamask, pass it to the Merkle tree, and check if the user is whitelisted. If so, the proof will be sent to the dApp without any further action from the user. Upon clicking the mint button, the proof and the mint amount will be sent to the whitelist mint function. This feature is crucial for the overall functionality of the dApp.

Smart Contract

In order to begin, it is important to ensure that Node.js and npm are properly installed on your system. To verify this, open the terminal and enter the command node -v and npm -v. If Node.js is not present, please follow the instructions for installation provided in the Node.js documentation. Similarly, if npm is not installed, please refer to the npm documentation for installation instructions.

Once Node.js and npm have been confirmed as properly installed, initialize an npm project by running npm init in the terminal. This will create a package.json file, which will be used to manage project dependencies, scripts, and other relevant information.

To proceed, install the Hardhat tooling framework by running npm install --save-dev hardhat in the terminal. Once installed, navigate to the directory where Hardhat is located and run npx hardhat. When prompted, select the option to "Create a JavaScript project and use the default settings for the remaining options. Hardhat provides a variety of tools, but for this project, we will be using the plugin recommended by Hardhat, npm install --save-dev @nomaticfoundation/hardhat-toolbox.

require("@nomicfoundation/hardhat-toolbox");

/** @type import('hardhat/config').HardhatUserConfig */
module.exports = {
  solidity: "0.8.9",
};

To gain access to the OpenZeppelin libraries, run npm install @openzeppelin/contracts in the terminal. Additionally, we will be using the ERC-721A contract created by cygaar, which can be obtained by running npm install --save-dev erc721.

Navigate to the contracts folder and open the solidity file. At this time, feel free to rename the file to a more appropriate name for the project. For this project, I have chosen to name it "RockPaperScissors" as the NFTs will be based on this theme. Inside the solidity file, import the necessary libraries as the contract will inherit them.


SPDX-License-Identifier: MIT

pragma solidity >=0.8.9 <0.9.0;

import "erc721a/contracts/extensions/ERC721AQueryable.sol";
import "@openzeppelin/contracts/utils/Strings.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";

import "hardhat/console.sol";

contract RockPaperScissors is ERC721AQueryable, Ownable, ReentrancyGuard 

It is important to note that I have chosen to make the merkleRoot variable immutable, which means it is passed in during the initialization of the contract. This approach has a few drawbacks, such as the whitelist must be finalized prior to deploying the contract, and any changes to the whitelist will cause a change in the Merkle tree, resulting in a different hash of the root. The reason for this decision is to provide users with confidence that the whitelist will not change once the contract is deployed. If the ability to change the Merkle root is desired, the immutable keyword can be removed and a function can be created to update the Merkle root when called by the owner.

The paused, whitelistMintEnabled, and revealed variables allow for control over the different phases of the project. The pause variable should be set to "true" upon deployment to prevent users from accessing the mint function and minting tokens from the collection. The whitelistMintEnabled variable should be set to false to prevent whitelisted users from accessing the whitelist mint function until the whitelist period has begun. The revealed variable should also be set to false to prevent users from accessing the metadata of the NFTs.

The whitelistClaimed mapping is used to keep track of the number of tokens that have been minted by whitelisted users. The address is mapped to a uint instead of the traditional boolean option, which allows for the flexibility to mint the limit in separate transactions.


  // =============================================================
  //                          CONSTANTS
  // =============================================================
  uint public constant MAX_SUPPLY = 777;

  // =============================================================
  //                          VARIABLES
  // =============================================================
  uint256 public immutable maxMintAmountPerTx;
  bytes32 public immutable merkleRoot;

  uint public price;
  uint public wlPrice;

  bool public paused = true;
  bool public whitelistMintEnabled = false;
  bool public revealed = false;

  mapping(address => uint) public whitelistClaimed;

  // =============================================================
  //                          METADATA
  // =============================================================
  string public uriPrefix = "";
  string public uriSuffix = ".json";
  string public hiddenMetadataUri;

  // =============================================================
  //                          CONSTRUCTOR
  // =============================================================
  constructor(
    uint256 _maxMintAmountPerTx,
    uint _price,
    uint _wlprice,
    string memory _hiddenMetadataUri,
    bytes32 _merkleRoot
  ) ERC721A("RockPaperScissors", "RPS") {
    maxMintAmountPerTx = _maxMintAmountPerTx;
    setPrice(_price);
    setWLPrice(_wlprice);
    setHiddenMetadataUri(_hiddenMetadataUri);
    merkleRoot = _merkleRoot;
  }

The constructor of the contract is quite straightforward. Upon deployment, four arguments are passed to the contract, which are used to set the state variables or call setters that write to the state variables. The name and symbol of the project's token are passed directly to the ERC721A constructor.

Additionally, three internal modifiers have been created to be used in some of the functions within the contract. The callerIsUser modifier is used to prevent a contract from calling the function. This is implemented to help prevent phishing attacks. For more information on the subject, David has written an article that explains the details about tx.origin and msg.sender. The image below illustrates the difference between the two.

tx.origin vs msg.sender
tx.origin vs msg.sender

The mintCompliance modifier is used to ensure that the mint amount is greater than 0 and less than or equal to the maximum batch per transaction. This helps to ensure that the minting process is conducted in a compliant manner.

Similarly, the mintPriceCompliance modifier is used to ensure that the value sent is greater than or equal to the price (price * mint amount). This helps to ensure that the minting process is conducted in a financially compliant manner.

By implementing these three modifiers, the code is kept clean and organized, as the require statements do not need to be repeated in various functions. This improves the readability and maintainability of the code.

  // =============================================================
  //                          INTERNAL MODIFIERS
  // =============================================================

  /**
   * @dev Ensure a Smart Contract is not interfacing with this contract.
   * msg.sender can either be an account address or smart contract address, while
   * tx.origin will always be the account/wallet address.
   */
  modifier callerIsUser() {
    require(tx.origin == msg.sender, "The caller is another contract");
    _;
  }
  modifier mintCompliance(uint256 _mintAmount) {
    require(
      _mintAmount > 0 && _mintAmount <= maxMintAmountPerTx,
      "Invalid mint amount!"
    );
    require(totalSupply() + _mintAmount <= MAX_SUPPLY, "Max supply exceeded!");
    _;
  }

  modifier mintPriceCompliance(uint256 _mintAmount) {
    require(msg.value >= price * _mintAmount, "Insufficient funds!");
    _;
  }

The mint function is relatively simple in its implementation. When called, the function first checks that the conditions set by the modifiers are met. If they are, the function proceeds to check if the contract is currently paused. If the contract is not paused, the _safeMint function is called and the transaction is completed. It is worth noting that the _safeMint function includes additional checks that are not covered in the scope of this article.

The whitelistMint function is passed the number of tokens the user wishes to mint, as well as proof associated with their wallet. As with the "mint" function, the conditions set by the modifiers are checked before proceeding with the function. If the conditions are met, the function checks if whitelist minting is enabled. Next, it checks if the user has already claimed their allotted number of tokens. Assuming these conditions are met, the leaf variable is assigned a hashed value of the user's address. The "MerkleProof" function's "verify" function is then called, passing in three arguments: _merkleProof (which is currently passed manually by the user, but will eventually be handled by the dApp), merkleRoot (set during deployment), and leaf (hashed value of the user's address). The "verify" function returns "true" if the leaf can be proven to be a part of the Merkle tree defined by merkleRoot. If the function returns "true", the number of tokens minted is added to the user's mapping and the _safeMint function is called. Additional information regarding the creation of a Merkle tree will be provided later in this section.

The airdrop function is similarly simple in implementation. As with the previous functions, the function begins by checking the conditions set by the onlyOwner and callerIsUser. In most cases, the airdrop function should only be called by the owner. The "airdrop" function can be used for marketing purposes or to distribute tokens to the development team. After the conditions set by the modifiers are met, the function checks that the total supply of minted tokens plus the amount being airdropped is less than the maximum supply. If this condition is met, the _safeMint function is called.

  // =============================================================
  //                          INTERNAL MINT LOGIC
  // =============================================================

  function mint(
    uint256 _mintAmount
  )
    public
    payable
    callerIsUser
    mintCompliance(_mintAmount)
    mintPriceCompliance(_mintAmount)
  {
    require(!paused, "The contract is paused!");

    _safeMint(msg.sender, _mintAmount);
  }

  function whitelistMint(
    uint256 _mintAmount,
    bytes32[] calldata _merkleProof
  )
    public
    payable
    callerIsUser
    mintCompliance(_mintAmount)
    mintPriceCompliance(_mintAmount)
  {
    require(whitelistMintEnabled, "The whitelist sale is not enabled!");
    require(whitelistClaimed[msg.sender] < 2, "Address already claimed!");
    bytes32 leaf = keccak256(abi.encodePacked(msg.sender));
    require(
      MerkleProof.verify(_merkleProof, merkleRoot, leaf),
      "Invalid proof!"
    );

    whitelistClaimed[msg.sender] += _mintAmount;
    _safeMint(msg.sender, _mintAmount);
  }

  function airdrop(
    address wallet,
    uint256 amount
  ) external onlyOwner callerIsUser {
    require(totalSupply() + amount < MAX_SUPPLY + 1, "Exceed max supply!");

    _safeMint(wallet, amount);
  }

Since I mentioned a few of these variables earlier, I thought I would show their setter functions. Again, pretty straightforward. Call the function, satisfy the modifier, and set the state variable with the argument that was passed.

  // =============================================================
  //                          SETTERS
  // =============================================================
  function setPrice(uint _price) public onlyOwner {
    price = _price;
  }

  function setWLPrice(uint _wlprice) public onlyOwner {
    wlPrice = _wlprice;
  }

  function setPaused(bool _state) public onlyOwner {
    paused = _state;
  }

  function setWhitelistMintEnabled(bool _state) public onlyOwner {
    whitelistMintEnabled = _state;
  }

  function setRevealed(bool _state) public onlyOwner {
    revealed = _state;
  }

Merkle Tree

In order to proceed with creating a Merkle tree, it is necessary to install the "keccak256" and "merkletreejs" packages via npm. This can be done by running npm install keccak256 and merkletreejsin the terminal. Once these packages have been installed, it is recommended to create a folder structure for this part of the project. In this example, a folder called "utils" is created, which will contain two files named "addresses.json" and "merkletree.js". The "addresses.json" file will store the array of addresses that should be whitelisted.

Inside the terminal, run npm install keccak256 and merkletreejs. Once installed, create a folder structure for this portion of the project. In this example, I created a folder called utils with two files called addresses.json and merkletree.js. Inside the addresses.json file, I store the array of addresses that should be whitelisted.

[
  "0x70997970C51812dc3A010C7d01b50e0d17dc79C8",
  "0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65",
  "0x9965507D1a55bcC2695C58ba16FB37d819B0A4dc",
  "0x976EA74026E726554dB657fA54763abd0C3a0aa9",
  "0x14dC79964da2C08b23698B3D3cc7Ca32193d9955",
  "0x23618e81E3f5cdF7f54C3d65f7FBc0aBf5B21E8f",
  "0xa0Ee7A142d267C1f36714E4a8F75612F20a79720",
  "0xBcd4042DE499D14e55001CcbB24a551F3b954096",
  "0x71bE63f3384f5fb98995898A86B02Fb2426c5788",
  "0xFABB0ac9d68B0B445fB7357272Ff202C5651694a",
  "0x1CBd3b2770909D4e10f157cABC84C7264073C9Ec",
  "0xdF3e18d64BC6A983f673Ab319CCaE4f1a57C7097",
  "0xcd3B766CCDd6AE721141F452C550Ca635964ce71",
  "0x2546BcD3c84621e976D8185a91A922aE77ECEc30",
  "0xbDA5747bFD65F08deb54cb465eB87D40e51B197E",
  "0xdD2FD4581271e230360230F9337D5c0430Bf44C0",
  "0x8626f6940E2eb28930eFb4CeF49B2d1F2C9C1199",
  "0xAb8483F64d9C6d1EcF9b849Ae677dD3315835cb2",
  "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"
]

Inside the merkletree.js file, add the following code. We create two variables for the installs we did earlier. Next, we store the addresses from our .json inside the variable called addresses. Now we create our Merkle tree (leaves, tree, root, leaf, and proof). As I mentioned earlier, without the dApp handling the proof, we have to manually do it. Line 8, I specify which address in the array I want to be hashed and assigned to leaf. Line 9, I call the getProof, which returns and assigns the hashed values of the neighbor leaves and all parent nodes. This data is important because it allows us to derive the Merkle tree root hash. The commented lines can be used for testing valid and invalid proofs. If you want to learn more, Peter Blockman created a great article explaining Merkle trees for NFT whitelist minting.

const { MerkleTree } = require("merkletreejs");
const keccak256 = require("keccak256");
const addresses = require("./addresses.json");

const leaves = addresses.map((x) => keccak256(x));
const tree = new MerkleTree(leaves, keccak256, { sort: true });
const root = tree.getRoot().toString("hex");
const leaf = keccak256(addresses[18]);
const proof = tree.getProof(leaf);

// console.log(tree.verify(proof, leaf, root)); // true

// const badLeaves = addresses.map((x) => keccak256(x));
// const badTree = new MerkleTree(badLeaves, keccak256);
// const badLeaf = keccak256("0xAb8483F64d9C6d1EcF9b849Ae677dD3315835c69");
// const badProof = badTree.getProof(badLeaf);
// console.log(badTree.verify(badProof, badLeaf, root)); // false
// console.log(tree.toString());
console.log("0x" + tree.getRoot().toString("hex"));

const buf2hex = (x) => "0x" + x.toString("hex");
const AddressProof = tree.getProof(leaf).map((x) => buf2hex(x.data));
console.log(AddressProof);

Unit Testing

A powerful feature of Hardhat is the ability to unit test your contract. Below is an example of some of the unit testing I wrote for this contract. The entire file is 400 lines of code. Unit testing is extremely important for smart contracts. Remember, once deployed to the blockchain, you cannot undo it! Contracts will handle funds, which means you should be extremely cautious before deploying a contract into production. Unit testing syntax and explanations can be found here. After writing your unit test, it’s time to test them by running npx hardhat test. The result of each test will be written to the output window of your terminal. See the image below.

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

// =============================================================
//                          SMART CONTRACT - UNIT TESTING
// =============================================================
describe("RPS - Unit Testing", function () {
  let RockPaperScissors,
    rockPaperScissorsContract,
    owner,
    addr1,
    addr2,
    addr3,
    addrs;
  this.beforeEach(async function () {
    RockPaperScissors = await ethers.getContractFactory("RockPaperScissors");
    [owner, addr1, addr2, addr3, ...addrs] = await ethers.getSigners();
    rockPaperScissorsContract = await RockPaperScissors.deploy(
      5,
      ethers.BigNumber.from("1000000000000000000"), //1ETH
      ethers.BigNumber.from("1000000000000000000"), //1ETH
      "ipfs://QmZD66sq4Em1xQKSchg45q2AtTf8qts5LZMPx3kCqPYpQg/hidden.png",
      ethers.BigNumber.from(
        "0x04c8b7dc04c57c4e8e83fff68c0e603c2871484788a158e8c63854ecd5d256ee"
      )
    );
  });
  // =============================================================
  //                          DEPLOY
  // =============================================================
  describe("Deployment", function () {
    it("Should set the right owner", async function () {
      expect(await rockPaperScissorsContract.owner()).to.equal(owner.address);
    });
  });
  // =============================================================
  //                          MINT
  // =============================================================
  describe("mint", function () {
    it("Should revert because _mintAmount is 0", async function () {
      await rockPaperScissorsContract.connect(owner).setPaused(false);
      expect(await rockPaperScissorsContract.paused()).to.equal(false);
      const expectedValue = 0;
      const overrides = {
        value: ethers.utils.parseEther("0.1"),
      };

      await expect(
        rockPaperScissorsContract.connect(owner).mint(expectedValue, overrides)
      ).to.be.revertedWith("Invalid mint amount!");
    });
    it("Should revert because insufficient funds were sent", async function () {
      await rockPaperScissorsContract.connect(owner).setPaused(false);
      expect(await rockPaperScissorsContract.paused()).to.equal(false);
      const expectedValue = 1;
      const overrides = {
        value: ethers.utils.parseEther("0.1"),
      };

      await expect(
        rockPaperScissorsContract.connect(owner).mint(expectedValue, overrides)
      ).to.be.revertedWith("Insufficient funds!");
    });
    it("Should revert because contract is paused", async function () {
      const expectedValue = 1;
      const overrides = {
        value: ethers.utils.parseEther("5"),
      };

      await expect(
        rockPaperScissorsContract.connect(owner).mint(expectedValue, overrides)
      ).to.be.revertedWith("The contract is paused!");
    });
    it("Should mint x tokens", async function () {
      await rockPaperScissorsContract.connect(owner).setPaused(false);
      expect(await rockPaperScissorsContract.paused()).to.equal(false);
      const expectedValue = 1;
      const overrides = {
        value: ethers.utils.parseEther("5"),
      };

      await rockPaperScissorsContract
        .connect(owner)
        .mint(expectedValue, overrides);
    });
  });
Output Example
Output Example

Art Engine

To generate the NFTs for this project, I will be using Hashlip’s Art Engine. The GitHub repository for the Art Engine can be found here. I will not cover every step, as Daniel does a great job covering them in his YouTube video.

This art engine allows you to turn image layers into thousands of unique code-generated artworks. Once the repo has been downloaded, head into the config.js file and look for the following variables (namePrefix, description, growEditionSizeTo, and layersOrder). Modify these variables to meet your project needs. Once modified, cd into the art engine’s directory (i.e. cd hashlips_art_engine-1.1.2_patch_v6)and run npm run generate. This script will run the index.js file, which calls the main.js file. This file does most of the generative work. In the image below, the codebase starts to generate each image’s metadata.

npm run generate
npm run generate

Once finished, head to the build folder and review the images and json folders. Select a .json file and review the contents. The variables from the config.js have been applied to the collection, which will be shown on the marketplace later in this article.

json Metadata
json Metadata

Now that our collection’s metadata has been built, we need to upload our collection to IPFS. IPFS or InterPlantery File System is a P2P network for storing and sharing data in a distributed file system. IPFS uses content-addressing to uniquely identify each file in a global namespace connecting IPFS hosts. An overview of the protocol can be found here, as well as how to install the desktop application. In summary, most NFTs are stored as metadata that points to an image off-chain where the actual file is hosted.

Once installed, navigate to the Files section and click +import. Select the Folder option in the drop-down and select the images folder for the collection. Repeat the same steps for the .json folder.

IPFS Desktop
IPFS Desktop

Click … for the collection’s metadata (.json) and select Copy CID. The CID identifier will be passed to our Smart Contract in a later section.

When files are uploaded to IPFS, the file is run through a cryptographic algorithm that gives you something called the Content Identifier, or CID. Every CID is determined by the content of the file, making it completely unique. This allows content to be verifiable since two images would have completely different CIDs. Combine this power with the blockchain, and you get a reference to an image that is verified and cannot be changed.

Copy CID
Copy CID

Utilizing Etherscan’s ability to communicate to our contract, connect your Metamask wallet and write to the uriPrefix function. Before pasting in the CID, type ipfs:// in front of the string. After writing to the function, head to the Read Contract tab and see the value of the uriPrefix variable. It should look similar to the image below but with your unique CID.

uriPrefix
uriPrefix

Now that our contract has the metadata needed, let’s review how marketplaces use this data to display the NFT image, description, and properties. Markplaces use the properties of the collection to determine a percentage of trait, which can be used to determine rarity of an NFT. When creating layers, keep in mind that the names for each layer folder and individual images are used for the metadata and will be displayed on each NFT.

Metadata Example
Metadata Example

As the contract owner, you can set the variable revealed to true, which will use the uriPrefix and uriSuffix. If the revealed variable is set to false, the owner can use a default image for all tokens until the collection is ready to be revealed. Before revealing the RPS collection, all tokens pointed to the variable hiddenMetadataUri, which pointed the tokens to a CID of the image below.

Default Collection Image
Default Collection Image

dApp

For the dApp, I will be using Thirdweb’s React SDK for the frontend. To get started run npx thirdweb@latest create app --evm in your terminal. You will be asked to give your project a name, framework (React), and language (JavaScript). Once installed, cd into the react folder (project name) and run npm run start to spin up a local instance of the application.

Local React Instance
Local React Instance

Inside the index.js file, add your ChainId (network) that your contract is deployed to. For this project, the contract was deployed to the Goerli network.

Setting Chain ID
Setting Chain ID

Now, it’s time to start reading data from our contract on the Goerli network. Create a folder inside of the src folder called components. Inside the components folder, create a file called mint.js. Utilizing Thirdweb’s React hook useContractRead, we can read a function, view, or mapping. For this example, I will be reading the contract’s price, paused, and whitelistEnabled functions. Using variables to store the value, allows the developer to display the state on the dApp (later section).

useContractRead Examples
useContractRead Examples

Let’s start to display the data from our contract onto the dApp.

First, we’ll want to display the price of the NFT. To do so, we can use the price variable, which was declared and stored in the earlier section. Once added, we need to format the units from wei to ether and show a default message to the user.

Next, we should display the state of the contract to the user and developer. This should include whether the contract is paused and if the whitelist is enabled.

Lastly, after the user has connected their wallet and selected the right network, we should display the Mint button, which will mint one NFT.

Displaying Data
Displaying Data

Using some basic styling, here’s an example of what the dApp will look like.

dApp Overview
dApp Overview

Etherscan Contract

Subscribe to The HMI Guy
Receive the latest updates directly to your inbox.
Mint this entry as an NFT to add it to your collection.
Verification
This entry has been permanently stored onchain and signed by its creator.