Minting Strategies

Difficulty: Intermediate

NFT minting strategies have evolved significantly since the early days of simple sequential minting. Modern NFT launches require careful engineering to handle high demand, prevent bot manipulation, ensure fair distribution, and optimize gas costs. The choice of minting strategy can make or break an NFT project - poorly designed mints have resulted in gas wars costing users thousands of dollars, failed transactions, and even temporary network congestion on Ethereum mainnet. Understanding the available strategies and their tradeoffs is essential for any smart contract developer working with NFTs.

Lazy minting (also called off-chain minting or gasless minting) defers the on-chain minting transaction to the moment of purchase. Instead of the creator paying gas to mint all tokens upfront, the creator signs metadata off-chain (using EIP-712 typed data signatures), and the buyer pays the gas to both mint and purchase in a single transaction. This approach is used by marketplaces like Rarible and OpenSea's 'Create' feature. The signed voucher contains the token ID, metadata URI, price, and the creator's signature. The smart contract verifies the signature during the mint-and-buy transaction, ensuring only authorized tokens can be minted. This eliminates upfront costs for creators and avoids the need to predict demand.

Whitelist (allowlist) minting using Merkle proofs is the standard approach for giving early access to selected wallets. Instead of storing thousands of addresses on-chain (which would be extremely expensive), the project generates a Merkle tree from the whitelist addresses off-chain and stores only the root hash (32 bytes) in the contract. When a whitelisted user wants to mint, they submit their Merkle proof - a set of sibling hashes that, when combined with the user's address, reconstruct the root. The contract verifies this proof in O(log n) time and O(log n) calldata. For a 10,000-address whitelist, each proof requires only about 14 hashes (ceil(log2(10000))), making it extremely gas-efficient compared to storing all addresses in a mapping.

Dutch auction minting starts at a high price and decreases linearly over time until the collection sells out or reaches a floor price. This mechanism is an effective price discovery tool - instead of guessing the right mint price (set too high and the mint fails, set too low and you leave money on the table), the market determines the fair price. Dutch auctions also naturally reduce gas wars because there's less incentive to rush: if the current price is too high, you can simply wait for it to decrease. Successful Dutch auctions often include a refund mechanism where buyers who paid more than the final clearing price receive the difference back, ensuring fairness.

Reveal mechanics add suspense to NFT collections. In a standard reveal, all tokens are minted with hidden metadata (showing a placeholder image), and the real metadata is revealed later - usually by changing the baseURI from the placeholder to the actual IPFS directory. To prevent the team from cherry-picking rare tokens before reveal, projects use a provenance hash: they hash all metadata in order before mint, publish this hash on-chain, and then use a random offset (often from Chainlink VRF) to shuffle the assignment of metadata to token IDs. The provenance hash allows anyone to verify post-reveal that the metadata was not tampered with.

Code examples

Whitelist Minting with Merkle Proof

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

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol";
import "@openzeppelin/contracts/utils/Strings.sol";

contract MerkleWhitelistNFT is ERC721, Ownable {
    using Strings for uint256;

    enum MintPhase { Paused, Whitelist, Public }

    bytes32 public merkleRoot;
    MintPhase public currentPhase;
    string public baseURI;

    uint256 public totalSupply;
    uint256 public constant MAX_SUPPLY = 5000;
    uint256 public constant WL_PRICE = 0.05 ether;
    uint256 public constant PUBLIC_PRICE = 0.08 ether;
    uint256 public constant MAX_PER_WALLET = 3;

    mapping(address => uint256) public mintedCount;

    constructor(
        bytes32 _merkleRoot,
        string memory _baseURI
    ) ERC721("MerkleNFT", "MNFT") Ownable(msg.sender) {
        merkleRoot = _merkleRoot;
        baseURI = _baseURI;
    }

    function whitelistMint(
        uint256 quantity,
        bytes32[] calldata merkleProof
    ) external payable {
        require(currentPhase == MintPhase.Whitelist, "WL not active");
        require(totalSupply + quantity <= MAX_SUPPLY, "Exceeds supply");
        require(
            mintedCount[msg.sender] + quantity <= MAX_PER_WALLET,
            "Exceeds per-wallet limit"
        );
        require(msg.value >= WL_PRICE * quantity, "Insufficient ETH");

        bytes32 leaf = keccak256(abi.encodePacked(msg.sender));
        require(
            MerkleProof.verify(merkleProof, merkleRoot, leaf),
            "Invalid Merkle proof"
        );

        mintedCount[msg.sender] += quantity;
        for (uint256 i = 0; i < quantity; i++) {
            _safeMint(msg.sender, totalSupply++);
        }
    }

    function publicMint(uint256 quantity) external payable {
        require(currentPhase == MintPhase.Public, "Public not active");
        require(totalSupply + quantity <= MAX_SUPPLY, "Exceeds supply");
        require(
            mintedCount[msg.sender] + quantity <= MAX_PER_WALLET,
            "Exceeds per-wallet limit"
        );
        require(msg.value >= PUBLIC_PRICE * quantity, "Insufficient ETH");

        mintedCount[msg.sender] += quantity;
        for (uint256 i = 0; i < quantity; i++) {
            _safeMint(msg.sender, totalSupply++);
        }
    }

    function setPhase(MintPhase _phase) external onlyOwner {
        currentPhase = _phase;
    }

    function setMerkleRoot(bytes32 _merkleRoot) external onlyOwner {
        merkleRoot = _merkleRoot;
    }

    function tokenURI(
        uint256 tokenId
    ) public view override returns (string memory) {
        require(tokenId < totalSupply, "Nonexistent token");
        return string(abi.encodePacked(baseURI, tokenId.toString(), ".json"));
    }

    function withdraw() external onlyOwner {
        (bool success, ) = msg.sender.call{value: address(this).balance}("");
        require(success, "Withdraw failed");
    }
}

This contract uses OpenZeppelin's MerkleProof library to verify whitelist membership. The Merkle root is stored on-chain (32 bytes), and each whitelisted user provides their proof during minting. This is dramatically cheaper than storing thousands of addresses in a mapping.

Generating Merkle Tree (JavaScript)

// JavaScript: Generate Merkle tree for whitelist
const { MerkleTree } = require('merkletreejs');
const { keccak256 } = require('ethers');
const { solidityPackedKeccak256 } = require('ethers');

const whitelist = [
  '0x1234567890AbCdEf1234567890AbCdEf12345678',
  '0xABCDEF0123456789ABCDEF0123456789ABCDEF01',
  '0x9876543210FeDcBa9876543210FeDcBa98765432',
  '0xDeadBeefDeadBeefDeadBeefDeadBeefDeadBeef',
];

const leaves = whitelist.map(addr =>
  keccak256(solidityPackedKeccak256(['address'], [addr]))
);

const tree = new MerkleTree(leaves, keccak256, { sortPairs: true });

const root = tree.getHexRoot();
console.log('Merkle Root:', root);

function getProof(address) {
  const leaf = keccak256(solidityPackedKeccak256(['address'], [address]));
  return tree.getHexProof(leaf);
}

const proof = getProof(whitelist[0]);
console.log('Proof for', whitelist[0]);
console.log(proof);

const leaf = keccak256(
  solidityPackedKeccak256(['address'], [whitelist[0]])
);
console.log('Valid proof:', tree.verify(proof, leaf, root));

This script builds a Merkle tree from a list of whitelisted addresses. The root is stored on-chain in the contract constructor. When a user wants to mint, the frontend calls getProof with their address and passes the resulting array to the whitelistMint function.

Dutch Auction Mint

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

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/access/Ownable.sol";

contract DutchAuctionNFT is ERC721, Ownable {
    uint256 public constant MAX_SUPPLY = 8000;
    uint256 public constant MAX_PER_TX = 5;

    uint256 public immutable startPrice;
    uint256 public immutable endPrice;
    uint256 public immutable startTime;
    uint256 public immutable duration;
    uint256 public immutable priceDropInterval;

    uint256 public totalSupply;
    uint256 public finalPrice;

    mapping(address => uint256) public mintedBy;
    mapping(address => uint256) public paidBy;

    constructor(
        uint256 _startPrice,
        uint256 _endPrice,
        uint256 _startTime,
        uint256 _duration,
        uint256 _priceDropInterval
    ) ERC721("DutchAuctionNFT", "DANFT") Ownable(msg.sender) {
        startPrice = _startPrice;
        endPrice = _endPrice;
        startTime = _startTime;
        duration = _duration;
        priceDropInterval = _priceDropInterval;
    }

    function getCurrentPrice() public view returns (uint256) {
        if (block.timestamp < startTime) return startPrice;
        uint256 elapsed = block.timestamp - startTime;
        if (elapsed >= duration) return endPrice;

        uint256 steps = elapsed / priceDropInterval;
        uint256 totalSteps = duration / priceDropInterval;
        uint256 priceDrop = ((startPrice - endPrice) * steps) / totalSteps;

        return startPrice - priceDrop;
    }

    function mint(uint256 quantity) external payable {
        require(block.timestamp >= startTime, "Auction not started");
        require(finalPrice == 0, "Sold out");
        require(quantity > 0 && quantity <= MAX_PER_TX, "Invalid quantity");
        require(totalSupply + quantity <= MAX_SUPPLY, "Exceeds supply");

        uint256 price = getCurrentPrice();
        require(msg.value >= price * quantity, "Insufficient ETH");

        paidBy[msg.sender] += msg.value;
        mintedBy[msg.sender] += quantity;

        for (uint256 i = 0; i < quantity; i++) {
            _safeMint(msg.sender, totalSupply++);
        }

        if (totalSupply == MAX_SUPPLY) {
            finalPrice = price;
        }
    }

    function claimRefund() external {
        require(finalPrice > 0, "Mint not sold out yet");
        uint256 minted = mintedBy[msg.sender];
        require(minted > 0, "Did not mint");

        uint256 totalPaid = paidBy[msg.sender];
        uint256 fairPrice = finalPrice * minted;

        if (totalPaid > fairPrice) {
            uint256 refund = totalPaid - fairPrice;
            paidBy[msg.sender] = fairPrice;
            (bool success, ) = msg.sender.call{value: refund}("");
            require(success, "Refund failed");
        }
    }

    function endAuction() external onlyOwner {
        require(finalPrice == 0, "Already ended");
        finalPrice = getCurrentPrice();
    }

    function withdraw() external onlyOwner {
        require(finalPrice > 0, "Auction not ended");
        (bool success, ) = msg.sender.call{value: address(this).balance}("");
        require(success, "Withdraw failed");
    }
}

This Dutch auction starts at a high price and decreases over time. The refund mechanism ensures fairness: early buyers who paid more can claim the difference between what they paid and the final clearing price. This eliminates the penalty for minting early.

Lazy Minting with EIP-712 Signatures

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

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";

contract LazyMintNFT is ERC721, EIP712 {
    using ECDSA for bytes32;

    struct MintVoucher {
        uint256 tokenId;
        string tokenURI;
        uint256 price;
        address creator;
    }

    bytes32 private constant VOUCHER_TYPE_HASH = keccak256(
        "MintVoucher(uint256 tokenId,string tokenURI,uint256 price,address creator)"
    );

    mapping(uint256 => string) private _tokenURIs;
    mapping(uint256 => bool) public voucherUsed;

    constructor()
        ERC721("LazyMintNFT", "LAZY")
        EIP712("LazyMintNFT", "1")
    {}

    function redeem(
        MintVoucher calldata voucher,
        bytes calldata signature
    ) external payable {
        require(!voucherUsed[voucher.tokenId], "Voucher already used");
        require(msg.value >= voucher.price, "Insufficient payment");

        bytes32 structHash = keccak256(abi.encode(
            VOUCHER_TYPE_HASH,
            voucher.tokenId,
            keccak256(bytes(voucher.tokenURI)),
            voucher.price,
            voucher.creator
        ));
        bytes32 digest = _hashTypedDataV4(structHash);
        address signer = digest.recover(signature);
        require(signer == voucher.creator, "Invalid signature");

        voucherUsed[voucher.tokenId] = true;

        _safeMint(msg.sender, voucher.tokenId);
        _tokenURIs[voucher.tokenId] = voucher.tokenURI;

        (bool success, ) = voucher.creator.call{value: msg.value}("");
        require(success, "Payment failed");
    }

    function tokenURI(
        uint256 tokenId
    ) public view override returns (string memory) {
        require(bytes(_tokenURIs[tokenId]).length > 0, "Nonexistent token");
        return _tokenURIs[tokenId];
    }
}

Lazy minting uses EIP-712 typed data signatures so creators can 'mint' NFTs without paying gas. The creator signs a voucher off-chain containing the token details and price. When a buyer calls redeem, the contract verifies the signature, mints the token, and forwards payment to the creator.

Key points

Concepts covered

Lazy Minting, Merkle Proof, Whitelist, Dutch Auction, Reveal Mechanics, Gas Optimization