DEV Community

Cover image for How to write NFT smart contract in 2023
Arif Rahman
Arif Rahman

Posted on

How to write NFT smart contract in 2023

Banner

Hello you fellow coders!

With the recent craze on NFT space, i ponder myself on how would one build an NFT smart contract. As with any emerging technology buzzword blockchain, businesses building NFT projects now will have the advantage of early adopters.

NFTs allow businesses to connect with their audience in new ways by providing exclusive value and experiences they wouldn't have been able to provide before.

Here i will guide through on how i build a somewhat a mid/advanced NFT smart contract.

nft smart contract todo list

In this project, here are the list of features which is common and the new OpenSea's On-Chain Enforcement Tool (operator-filter)

- Batch Minting (ERC721a)
Allowing user to mint multiple tokens within a single call.

- Whitelist / Allowlist
Using MerkleProof to validate user address if they are white-listed.

- Pre-Reveal / Reveal
Pre-reveal placeholders are used for new generative projects. The artwork wonโ€™t actually be revealed until the NFT is minted, and collectors will only know which NFT in the collection they own once they buy the NFT.

- OpenSea's On-Chain Royalties Enforcement Tool (operator-filter)
A smart contract that enforce creator royalties.


Initialise project

lets gooooo

In this tutorial, i will be using hardhat for smart contract development. Hardhat is a development environment for Ethereum software in a nutshell. You can even write test cases against your contract to ensure there no security issue, since once contract is deployed is immutable on the blockchain!

Lets start with creating a directory and installing hardhat as a dev dependency and initialise hardhat.

$ mkdir nft-smart-contract && cd nft-smart-contract && yarn add -D hardhat && npx hardhat
Enter fullscreen mode Exit fullscreen mode

Follow the steps given by hardhat, in my project i've selected typescript. This will then generates the necessary folder for you to start building your smart contract.

filder


Next installing dependency

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

As you can see we are installing erc721a an implementation of ERC721 for batch-minting and reduces gas fees during NFT Batch minting, credits to the @cygaar Azuki Dev. And also @openzeppelin which provides an open-source framework to build secure smart contracts.


Create our first NFT contract!

In the contracts folder create a new NftSample.sol file or name it to your choosing.

example image

In the example here, we're importing and inheriting ERC721A and Ownable.

ERC721A allow us to inherit their batch-minting function later when we modify our mint() function

Ownable which provides a basic access control mechanism, where there is an account (an owner) that can be granted exclusive access to specific functions. This module is used through inheritance.


Minting (Public)

minting

Next we will need to create our mint() function to allow user to mint multiple tokens. We also will set our token supply to 10k.

Here are some of the requirements we will be adding.

  • check if minting it available base on phases.
  • max limit per user address.
  • check if enough tokens left
  • check if the value is correct
   function mint(uint256 quantity) external payable {
        // check if public sale is live
        if (mintPhase != 2) revert PublicSaleNotLive();

        // check if enough token balance
        if (totalSupply() + quantity > maxSupply) {
            revert NotEnoughTokensLeft();
        }

        // check for the value user pass is equal to the quantity and the mintRate
        if (mintRate * quantity != msg.value) {
            revert WrongEther();
        }

        // check for user mint limit
        if (quantity + usedAddresses[msg.sender] > maxMints) {
            revert ExceededLimit();
        }

        usedAddresses[msg.sender] += quantity;
        _mint(msg.sender, quantity);
    }
Enter fullscreen mode Exit fullscreen mode

In the example above, we add a check if user are able to mint by setting a phase. We then check if theres enough token available to mint. To ensure user does not pass the wrong value, we've added a check and throw an error if so. Then we do a check if user has already minted the limit allowed by add mapping the user address and the quantity they have minted.

To allow user to batch-mint we inherit _mint() that derived from ERC721a and than we set the quantity user minted in a mapping function.


White list mint

wl mint

For our whitelist mint, there are couple of requirements to ensure of whitelist addresses are allowed to call this function. We will be using MerkleProof to verify user address.

    /**
     * @dev a function that only allow whitelisted addresses to mint
     */
    function whiteListMint(uint256 quantity, bytes32[] calldata proof)
        external
        payable
    {
        // check if white list sale is live
        if (mintPhase != 1) revert WhitelistNotLive();

        // check if the user is white listed.
        if (!isWhiteListed(msg.sender, proof)) revert InvalidMerkle();

        // check if enough token balance
        if (totalSupply() + quantity > maxSupply) {
            revert NotEnoughTokensLeft();
        }

        // check for the value user pass is equal to the quantity and the mintRate
        if (whiteListRate * quantity != msg.value) {
            revert WrongEther();
        }

        //check if user exceeded mint limit
        if (whiteListUsedAddresses[msg.sender] + quantity > whiteListMaxMints) {
            revert ExceededLimit();
        }

        _mint(msg.sender, quantity);
        // storing the number of minted items
        whiteListUsedAddresses[msg.sender] += quantity;
    }

    /**
     * @dev a function that check for user address and verify its proof
     */
    function isWhiteListed(address _account, bytes32[] calldata _proof)
        internal
        view
        returns (bool)
    {
        return _verify(leaf(_account), _proof);
    }

    function leaf(address _account) internal pure returns (bytes32) {
        return keccak256(abi.encodePacked(_account));
    }

    function _verify(bytes32 _leaf, bytes32[] memory _proof)
        internal
        view
        returns (bool)
    {
        return MerkleProof.verify(_proof, merkleRoot, _leaf);
    }

Enter fullscreen mode Exit fullscreen mode

As you can see in the example whiteListMint write function, there's 2 arguments, quantity & proof.

  • quantity - the number of tokens user would like to mint
  • proof - an array of hex strings which is required to verify with merkleRootHash that we will be adding later on.

We're adding isWhiteListed get function to check and return a boolean which uses MerkleProof.verify(_proof, merkleRoot, _leaf) which we inherit from @openzeppelin/contracts/utils/cryptography/MerkleProof.sol for verification.

In the nutshell, Merkle proof confirms specific transactions/values represented by a leaf or branch hash within a Merkle hash root, verify it and return a boolean.

Here the list of check for our, whiteListMint

  • check if whitelistmint is live.
  • check if user is whitelisted.
  • check the there token left to be minted.
  • check if the value send if correct for minting
  • check if user exceeded white list mint limit.

Merkle Root Hash

merkletree

In order to make whitelisting of addresses works, we need to generate merkle root hash which is a hex string and store in our contract. We will be adding a utils/scripts to do just that, in the example i'am using typescript, but you can use which ever programming language your prefer.

We will be installing the dependencies.

$ yarn add -D keccak265 merkletreejs
Enter fullscreen mode Exit fullscreen mode
// utils/merkle.ts

import { ethers } from "ethers";
import keccak256 from "keccak256";
import { MerkleTree } from "merkletreejs";

/**
 * This is to ensure the addresses is valid and injects the checksum
 */
export const getAndConvertAddresses = (addresses: string[]): string[] => {
  if (!addresses?.length) return [];

  const convertedAddress = addresses.map((addr) => {
    return ethers.utils.getAddress(addr);
  });

  return convertedAddress;
};

const generateMerkle = (addresses: string[]) => {
  if (!addresses?.length) return null;

  // This is to ensure the addresses is valid and injects the checksum
  const convertedAddresses = getAndConvertAddresses(addresses);

  /**
   * Create a new array of `leafNodes` by hashing all indexes of the `whitelistAddresses`
   * using `keccak256`. Then creates a Merkle Tree object using keccak256 as the algorithm.
   * The leaves, merkleTree, and rootHas are all PRE-DETERMINED prior to whitelist claim
   */
  const leafNodes = convertedAddresses.map((addr) => keccak256(addr));

  const merkleTree = new MerkleTree(leafNodes, keccak256, { sortPairs: true });
  const merkleRootHash = merkleTree.getRoot().toString("hex");
  const merkleTreeString = merkleTree.toString();

  return {
    merkleTree,
    merkleRootHash: `0x${merkleRootHash}`,
    merkleTreeString,
  };
};

/**
 * A function that generates merkle proof hex string
 * @param whitelistAddresses | string []
 * @param walletAddress | string
 * @returns string[]
 */
const getMerkleProof = (
  whitelistAddresses: string[],
  walletAddress: string
) => {
  const convertedAddresses = getAndConvertAddresses([walletAddress]);
  const merkle = generateMerkle(whitelistAddresses);
  const hashAddress = keccak256(convertedAddresses[0]);
  const proof = merkle?.merkleTree.getHexProof(hashAddress);
  return proof || [];
};

const isWhiteList = (
  whitelistAddresses: string[],
  walletAddress: string
): boolean => {
  const convertedAddresses = getAndConvertAddresses([walletAddress]);
  const merkle = generateMerkle(whitelistAddresses);
  const hashAddress = keccak256(convertedAddresses[0]);
  const proof = merkle?.merkleTree?.getHexProof(hashAddress) || [];
  const verify = merkle?.merkleTree.verify(
    proof,
    hashAddress,
    merkle?.merkleRootHash
  );

  return !!verify;
};

export { generateMerkle, getMerkleProof, isWhiteList, getAndConvertAddresses };

Enter fullscreen mode Exit fullscreen mode

In the example above, we created 4 functions.

getAndConvertAddresses - check if the address is a correct checksum addresses before we hash the addresses.

generateMerkle - a function that generate merkle root hash that we will be using and add in the contract.

getMerkleProof - a function that return us proof that will be use for white list mint.

isWhiteList - a function to check if the address is white listed. We can use this function to check in the client-side for e.g

Generating Merkle Root Hash

Next, we will be using generateMerkle() to generate our Merkle root hash and we can either set in our variable in the contract or set it later let's say their changes in the addresses.

script merkle

In the example above, we're simple creating a script and console log the output for our merkle root hash. Than we run the script.

$ npx ts-node scripts/generateMerkle.ts

The output will look something like this.

script output

For this example, i'am going to just add in our contract.

hasehs


coffee break

Phew that was a lot of codes, now let's take a coffee break, no serious you deserve it! โ˜•๏ธ


Mint Phases

There couple of ways to handle mint phases for minting. Like checking the block time stamp if matches a specific timestamp. But for this example for setting public or whitelist phases. We simply add a check for both mint() & whiteListMint() if the phases return a boolean.

 uint256 public mintPhase;

 function mint(uint256 quantity) external payable {
        // check if public sale is live
        if (mintPhase != 2) revert PublicSaleNotLive();

        ...
    }

    /**
     * @dev a function that only allow whitelisted addresses to mint
     */
    function whiteListMint(uint256 quantity, bytes32[] calldata proof)
        external
        payable
    {
        // check if white list sale is live
        if (mintPhase != 1) revert WhitelistNotLive();

        ...
    }


    /**
     * @dev a function to set mint phase
     */
    function setMintPhase(uint256 _phase) external onlyOwner {
        mintPhase = _phase;
    }

Enter fullscreen mode Exit fullscreen mode

In the example above, we create a setMintPhase() which accepts uint256 and only the owner can call this write function. This function allow only the owner to set the mint phases as such.

  • 1 = whitelist phase.
  • 2 = public phase.

TokenURI

As any NFT ERC721 contract, we need to include tokenUri get function to fetch the metadata of of that NFT by token id.

 function tokenURI(uint256 _tokenId)
        public
        view
        override
        returns (string memory)
    {
        require(_exists(_tokenId), "token does not exist!");
        if (revealed)
            return
                string(
                    abi.encodePacked(
                        baseURI,
                        _tokenId.toString(),
                        baseExtension
                    )
                );
        else {
            return
                string(
                    abi.encodePacked(
                        baseHiddenUri,
                        _tokenId.toString(),
                        baseExtension
                    )
                );
        }
    }
Enter fullscreen mode Exit fullscreen mode

tokenUri function accept an uint256 _tokenId, it check if the token exist. In our custom tokenUri function, we added a check for returning a revealed & unrevealed base on the the revealed boolean we added in our contract.


DefaultOperator by Opensea

How it works?

Token smart contracts may register themselves (or be registered by their "owner") with the OperatorFilterRegistry. Token contracts or their "owner"s may then curate lists of operators (specific account addresses) and codehashes (smart contracts deployed with the same code) that should not be allowed to transfer tokens on behalf of users.

    //  ==========================================
    //  ======== OPERATOR FILTER OVERRIDES =======
    //  ==========================================

    function transferFrom(
        address from,
        address to,
        uint256 tokenId
    ) public payable override onlyAllowedOperator(from) {
        super.transferFrom(from, to, tokenId);
    }

    function safeTransferFrom(
        address from,
        address to,
        uint256 tokenId
    ) public payable override onlyAllowedOperator(from) {
        super.safeTransferFrom(from, to, tokenId);
    }

    function safeTransferFrom(
        address from,
        address to,
        uint256 tokenId,
        bytes memory data
    ) public payable override onlyAllowedOperator(from) {
        super.safeTransferFrom(from, to, tokenId, data);
    }
Enter fullscreen mode Exit fullscreen mode

Let's wrap up ๐ŸŒฏ

For the rest of the implementation, you can have a look at my open source project here: nft-smart-contract

It's been a fun project to build while learning through all the required tools/libraries to make this works.

If you like my blog please support me by giving my project โญ๏ธ. You can reach me out at Linktree. And again thank you for reading ๐Ÿ“š and stay safe you unicorn ๐Ÿฆ„!

thank you

Top comments (0)