DEV Community

Cover image for Lazy Minting NFT - Solidity, Hardhat
Hussain'z
Hussain'z

Posted on • Edited on

Lazy Minting NFT - Solidity, Hardhat

What is NFT ?

Non-fungible tokens, often referred to as NFTs, are blockchain-based tokens that each represent a unique asset like a piece of art, digital content, or media.

Why Lazy Minting ?

Before understanding what is lazy minting lets understand why we really need it.When minting an NFT the owner needs to pay Gas fee which is a fee that the creator or the one who made the NFTs must pay in exchange for the computational energy needed to process and validate transactions on the blockchain.So with lazy minting we don't have to pay a gas fee when listing an NFT, We only pay gas fee when we actually mint the nft, once the asset is purchased and transfered on chain.

How it works

Usually when we mint an NFT we call the contract function directly & mint NFT on Chain.But in case of Lazy minting,Creator prepares a cryptographic signature with their wallet private key.
That cryptographic signature is know as "Voucher" which is then used to redeem NFT.It may also include some additional information required while minting the NFT on Chain.

Tech Stack

Solidity & Hardhat for smart contract development
React Js & Tailwind CSS for dapp

Let's start

By creating & understanding the signed voucher.In order to accomplish the signing we are going to use
EIP-712: Typed structured data hashing and signing
Which allows us to standardize signing a typed data structure, Which then can be sent to smart contract to claim the NFT.

eg.

struct SomeVoucher {
  uint256 tokenId;
  uint256 someVariable;
  uint256 nftPrice;
  string uri;
  bytes signature;
}
Enter fullscreen mode Exit fullscreen mode

Create a new directory called lazymint

> mkdir lazymint
> cd lazymint 
> yarn add -D hardhat

Enter fullscreen mode Exit fullscreen mode

Next, Initialize hardhat development environment

> yarn hardhat

888    888                      888 888               888
888    888                      888 888               888
888    888                      888 888               888
8888888888  8888b.  888d888 .d88888 88888b.   8888b.  888888
888    888     "88b 888P"  d88" 888 888 "88b     "88b 888
888    888 .d888888 888    888  888 888  888 .d888888 888
888    888 888  888 888    Y88b 888 888  888 888  888 Y88b.
888    888 "Y888888 888     "Y88888 888  888 "Y888888  "Y888

Welcome to Hardhat v2.10.2

? What do you want to do? â€Ķ 
â–ļ Create a JavaScript project
  Create a TypeScript project
  Create an empty hardhat.config.js
  Quit

Enter fullscreen mode Exit fullscreen mode

Select Create a JavaScript project and let it install all the dependencies.

Next, lets install a package called hardhat-deploy which makes working with hardhat 2x easier & fun ðŸ‘ŧ

> yarn add -D hardhat-deploy
Enter fullscreen mode Exit fullscreen mode

And add the following statement to your hardhat.config.js:

require('hardhat-deploy');

Enter fullscreen mode Exit fullscreen mode

Next,

yarn add -D @nomiclabs/hardhat-ethers@npm:hardhat-deploy-ethers ethers
Enter fullscreen mode Exit fullscreen mode

All the hardhat.config.js changes can be found in my REPO.

Next, Create a new contract in contracts directory feel free to give any name, i'll call it LazyMint.sol

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

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/cryptography/draft-EIP712.sol";
import "@openzeppelin/contracts/access/AccessControl.sol";

contract LazyMint is ERC721, ERC721URIStorage, Ownable, EIP712, AccessControl {

    error OnlyMinter(address to);
    error NotEnoughValue(address to, uint256);
    error NoFundsToWithdraw(uint256 balance);
    error FailedToWithdraw(bool sent);

    bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
    string private constant SIGNING_DOMAIN = "Lazy-Domain";
    string private constant SIGNING_VERSION = "1";

    event NewMint(address indexed to, uint256 tokenId);
    event FundsWithdrawn(address indexed owner, uint256 amount);

    struct LazyMintVoucher{
        uint256 tokenId;
        uint256 price;
        string uri;
        bytes signature;
    }

    constructor(address minter) ERC721("LazyMint", "MTK") EIP712(SIGNING_DOMAIN, SIGNING_VERSION) {
        _setupRole(MINTER_ROLE, minter);
    }


    function mintNFT(address _to, LazyMintVoucher calldata _voucher) public payable {
        address signer = _verify(_voucher);
        if(hasRole(MINTER_ROLE, signer)){
            if(msg.value >= _voucher.price){
                _safeMint(_to, _voucher.tokenId);
                _setTokenURI(_voucher.tokenId, _voucher.uri);
                emit NewMint(_to, _voucher.tokenId);
            }else{
                revert NotEnoughValue(_to, msg.value);
            }
        }else{
            revert OnlyMinter(_to);
        }
    }

    function _hash(LazyMintVoucher calldata voucher) internal view returns(bytes32){
        return _hashTypedDataV4(keccak256(abi.encode(
            //function selector
            keccak256("LazyMintVoucher(uint256 tokenId,uint256 price,string uri)"),
            voucher.tokenId,
            voucher.price,
            keccak256(bytes(voucher.uri))
        )));
    }

    function _verify(LazyMintVoucher calldata voucher) internal view returns(address){
        bytes32 digest = _hash(voucher);
        //returns signer
        return ECDSA.recover(digest, voucher.signature);
    }

    // The following functions are overrides required by Solidity.

    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 virtual override(AccessControl, ERC721) returns (bool){
        return ERC721.supportsInterface(interfaceId) || AccessControl.supportsInterface(interfaceId);
    }

    function withdrawFunds() public payable onlyOwner{
        uint256 balance = address(this).balance;
        if(balance <= 0){revert NoFundsToWithdraw(balance);}
        (bool sent,) = msg.sender.call{value: balance}("");
        if(!sent){revert FailedToWithdraw(sent);}
        emit FundsWithdrawn(msg.sender, balance);
    }
}
Enter fullscreen mode Exit fullscreen mode

Let's quickly go through the LazyMint.sol contract.
i am using ERC721 & ERC721URIStorage from OpenZeppelin Contracts

ERC721 - is a standard for representing ownership of non-fungible tokens, that is, where each token id is unique.

ERC721URIStorage - is an implementation of ERC721 that includes the metadata standard extensions ( IERC721Metadata ) as well as a mechanism for per-token metadata.

In Simple words, contract implemented without ERC721Storage generates tokenURI for tokenId on fly by concatenating baseURI + tokenID.In case of contracts using ERC721Storage we provide the tokenURI(metadata) when minting the token which is then stored on-chain.

Next, i am using Ownable & AccessControl.
Ownable - which enables contract exclusive access to functions eg. OnlyOwner has access to certain functions.

AccessControl - Which allows us to assign certain roles to address like in our case,Specific address can sign vouchers to mint NFTs, for that we can create a role called MINTER. or an admin for the overall contract.

Next, i have defined some custom errors which were introduced in solidity version 0.8.4

 error OnlyMinter(address to);
 error NotEnoughValue(address to, uint256);
 error NoFundsToWithdraw(uint256 balance);
 error FailedToWithdraw(bool sent);
Enter fullscreen mode Exit fullscreen mode

Next, we define the minter role, signing domain & version.
so that when minting we can check that the signed voucher explained above was signed by minter address.And includes the same domain and version.

 bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
    string private constant SIGNING_DOMAIN = "Lazy-Domain";
    string private constant SIGNING_VERSION = "1";
Enter fullscreen mode Exit fullscreen mode

Next, lets take a look at the Voucher which we defined in solidity using struct

struct LazyMintVoucher{
   uint256 tokenId;
   uint256 price;
   string uri;
   bytes signature;
}
Enter fullscreen mode Exit fullscreen mode

Which includes tokenId to mint, price, uri[usually a url pointing to metadata] for the nft,and signature.Let's Understand the mintNFT function to understand how this voucher does all the magic 🊄.

function mintNFT(address _to, LazyMintVoucher calldata _voucher) public payable {
  address signer = _verify(_voucher);
  if(hasRole(MINTER_ROLE, signer)){
     if(msg.value >= _voucher.price){
        _safeMint(_to, _voucher.tokenId);
        _setTokenURI(_voucher.tokenId, _voucher.uri);
        emit NewMint(_to, _voucher.tokenId);
     }else{
         revert NotEnoughValue(_to, msg.value);
     }
  }else{
     revert OnlyMinter(_to);
  }
}
Enter fullscreen mode Exit fullscreen mode

the important part of the voucher is signature which we sign off-chain with the Minters private key which has all the additional data in the same order defined in the above struct.Then in mintNFT function which expects two arguments _to & _voucher.
nft will be minted for _to address.And voucher basically helps redeem the NFT.First line in mintNFT function is to verify the signature from the voucher. to verify the signer we call a cryptographic function from draft-EIP712.sol called _hashTypedDataV4 which takes in our hashed version of our voucher struct and the return value then can be used with a recover function from Elliptic Curve Digital Signature Algorithm (ECDSA) to get the signer address.We then compare the recovered signer to check if it matches our MINTER address & also check if that signer has the minter_role, If Yes ? we proceed and check the value(eth passed) matches the price value mentioned in the voucher. If yes ? we go ahead and mint the token and emit an event emit NewMint(_to, _voucher.tokenId).

That's it for the Voucher Verification Magic Trick 🊄🊄🊄

Next, we have a withdrawFunds which allows only the contract owner to withdraw funds, if any.

How to sign a voucher ?

Open createSalesOrder, go to the scripts folder open createSalesOrder.js which is a simple script to create vouchers.

First we get the signer account.

const [signer] = await ethers.getSigners();

Enter fullscreen mode Exit fullscreen mode

Next, We need the signing domain & version these values should same as one defined in the contract.

const SIGNING_DOMAIN = "Lazy-Domain";
const SIGNING_VERSION = "1";
const MINTER = signer;
Enter fullscreen mode Exit fullscreen mode

As per EIP 712 we need a domain, which is made up of chainId, ContractAddress, domain & the version.

const domain = {
  name: SIGNING_DOMAIN,
  version: SIGNING_VERSION,
  verifyingContract: lazyMint.address,
  chainId: network.config.chainId
}    
let voucher = {
  tokenId: i,
  price: PRICE,
  uri: META_DATA_URI
}
Enter fullscreen mode Exit fullscreen mode

META_DATA_URI i have already pinned it to ipfs

`ghost_metadata.json`
{
    "description": "Boooooo",
    "image": "https://ipfs.io/ipfs/QmeueVyGRuTH939fPhGcPC8iF6HYhRixGBRmEgiZqFUvEW",
    "name": "Baby Ghost",
    "attributes": [
        {
            "trait_type": "cuteness",
            "value": 100
        }
    ]
}
Enter fullscreen mode Exit fullscreen mode

Next, we need the createVoucher function open voucherHelper.js. In order to create a signed voucher we need three arguments domain voucher types. Now types is nothing but the struct voucher which we have defined in solidity smart contract.Make sure the name of the struct & also the order of variables match.

const types = {
    LazyMintVoucher:[
        {name: "tokenId", type:"uint256"},
        {name: "price", type:"uint256"},
        {name: "uri", type:"string"}
    ]
}
Enter fullscreen mode Exit fullscreen mode

Next, we sign the voucher with the MINTER(signer) wallet & get the signed voucher 🖋ïļ

const signature = await signer._signTypedData(domain, types, voucher);
Enter fullscreen mode Exit fullscreen mode

In createSalesOrder.js at the end once i get the signed vouchers, In my case to keep things simple i am just saving the signed vouchers inside a file called NFTVouchers.json which i am creating directly in my dapp '../lazymintdapp/src/NFTVouchers.json'.Ideally in real life scenario you store these signed vouchers in your centralized DB ðŸĪŠ

Done! 🎉🎉

Contract Tests

I have written some unit tests which can be found in test/LazyMintUnit.test.js

> yarn hardhat test
Enter fullscreen mode Exit fullscreen mode

hardhat unit tests

Deployed Contract & Demo

https://ghost-mint.vercel.app/

https://mumbai.polygonscan.com/address/0x3077B8941e6337091FbB2E3216B1D5797B065C71

CODE REPO Don't forget to drop a ⭐⭐⭐⭐⭐

Ghost Dapp Demo

LazyNFTs Dapp

i won't be going through the dapp completely.But for reactjs dapp development i used create-react-app.

Packages

  • Wagmi: https://wagmi.sh/ for contract interaction, Cool and easy react hooks.And their documentation has lots of examples which makes it way easier to implement. ðŸĶ

Resources

There are multiple ways in which one can approach the lazy minting flow. this was all my learning experience.Here are some resources which help me understand lazy minting.

That's it on lazy minting. ðŸ‘ŧ

FunnyJIM

Buy Me A Coffee

Top comments (1)

Collapse
 
evan533 profile image
evan533 • Edited

nftschool.dev/tutorial/lazy-mintin...

Maybe you should add this to your resources