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;
}
Create a new directory called lazymint
> mkdir lazymint
> cd lazymint
> yarn add -D hardhat
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
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
And add the following statement to your hardhat.config.js:
require('hardhat-deploy');
Next,
yarn add -D @nomiclabs/hardhat-ethers@npm:hardhat-deploy-ethers ethers
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);
}
}
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);
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";
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;
}
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);
}
}
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();
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;
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
}
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
}
]
}
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"}
]
}
Next, we sign the voucher with the MINTER(signer) wallet & get the signed voucher ποΈ
const signature = await signer._signTypedData(domain, types, voucher);
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
Deployed Contract & Demo
https://ghost-mint.vercel.app/
https://mumbai.polygonscan.com/address/0x3077B8941e6337091FbB2E3216B1D5797B065C71
CODE REPO Don't forget to drop a βββββ
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.
- https://www.alchemy.com/overviews/lazy-minting
- https://eips.ethereum.org/EIPS/eip-712
- https://medium.com/metamask/eip712-is-coming-what-to-expect-and-how-to-use-it-bb92fd1a7a26
- https://nftschool.dev/tutorial/lazy-minting
That's it on lazy minting. π»
Top comments (1)
nftschool.dev/tutorial/lazy-mintin...
Maybe you should add this to your resources