Whitelist your NFT drop like a pro

Bored Founder
Artur Chmaro
|
July 21, 2022

Whitelist allows collection creators to decide which wallet addresses can participate in the mint. For example, when you run a great exclusive community (like BFC) you want to be sure that only the right people will get your token. Almost every NFT drop is exclusive to a specific set of public addresses. Otherwise, strangers or trading bots would be able to get your precious NFTs and run away with them to the secondary market. That's why whitelisting is super important. Most collection creators do not want such situations to happen.

Collecting public wallet addresses usually happens off-chain (without touching any blockchain stuff). All you have to do is to contact your people, collect their wallet addresses and prepare the list. You may use Google Forms or any other tooling for it. That's a pretty straightforward procedure, so I won’t cover it in this article. Way more problematic part is the smart contract code. The contract needs to validate if the caller is approved to participate in the mint. Secondly, the contract cannot allow callers to mint more tokens than expected.

Having all the above in mind let's dive into three common techniques of whitelisting and examine their pros and cons.

Storing whitelist in contract memory

If you just learned Solidity basics you might already have a clue about how to store a whitelist in the memory. The simplest approach is to just prepare mapping inside the smart contract:

-- CODE language-js -- mapping(address => uint256) private whitelistedAddrs;

Then we need to allow a certain address (owner of the collection or some other "power user" wallet) to update above whitelist:

-- CODE language-js -- function updateWhitelist(address _participant, uint amount) public onlyOwner { whitelistedAddrs[_participant] = _amount; }

Obviously, this function might be called only by "power users". It cannot be a function that anyone can call. That’s why I used “onlyOwner” modifier. You may read about it more here.

Whitelist logic is pretty much ready, so now we have to validate every address before the mint and update the whitelist afterward:

-- CODE language-js -- function mint() public { require(whitelistedAddrs[msg.sender] > 0, "not whitelisted") // your mint logic here // .... whitelistedAddrs[msg.sender] -= 1; }

That's it. Your contract now allows you to whitelist certain addresses for mint. Easy, huh?

However, this stupidly simple method is not ideal. The problem is with gas fees. Writing to contract memory is expensive. The owner of the collection needs to spend a lot of money on whitelisting. Costs cannot be covered by minters as only the owner can come up with the list. Of course, you may try to optimize the above code. Add batch functions and so on. That may reduce the costs, but only by a little amount. Whitelisting will be still damn expensive in comparison to other techniques.

Pros:

  • Easy to implement and deploy
  • Simple process of adding/kicking out somebody from the whitelist
  • No need to deliver anything to participants prior to mint

Cons:

  • Expensive

I personally recommend this technique for blockchains like Polygon/BSC/Arbitrum/Optimism because transactions are very cheap there and there is nothing to worry about.

Whitelisting 1k addresses may cost you ~$500 depending on current gas prices at mainnet.

As nobody wants to invest more than needed let’s take a look at cheaper solutions now.

Merkle tree whitelist

The second very popular solution is to use a data structure called Merkle Tree. The collection creator can generate Merkle tree completely off-chain without talking to the blockchain network and paying enormous gas fees.

The whole tree is built using secure cryptography. You may read about implementation details here. Anyway, that technique is powerful because you have total control of your whitelist and you do not have to spend too much on gas fees.

Before we dive into smart code let’s generate our proofs:

-- CODE language-js -- const { MerkleTree } = require("merkletreejs"); const keccak256 = require("keccak256"); const hashNode = ({ account, amount }) => Buffer.from(hre.ethers.utils.solidityKeccak256(['address', 'uint256'], [account, amount]).slice(2), 'hex'); const generateMerkleTree = async (whitelist) => { const leaves = whitelist.map(({ account, amount }) => hashNode({ account, amount })); const merkleTree = new MerkleTree(leaves, keccak256, { sortPairs: true }); const merkleRoot = merkleTree.getHexRoot(); const merkleProofs = leaves.map(item => merkleTree.getHexProof(item)) return { merkleRoot, merkleTree, merkleProofs }; } const main = async () => { const myWhitelist = [{ account: "0x8d37826DEBbdf3A8Db035b304631667efE7D1B52", amount: 2 }, { account: "0xFB6f522d0Ff7c3ACD6C9b0Ee036B70e6f27AC824", amount: 1 }]; const { merkleRoot, merkleTree, merkleProofs } = generateMerkleTree(myWhitelist); console.log({ merkleRoot, merkleProofs }) } main().then(() => process.exit(0)).catch((error) => { console.error(error); process.exit(1); });

Once the tree and proofs are generated, the smart contract needs to get logic for validating them:

-- CODE language-js -- // SPDX-License-Identifier: MITpragma solidity ^0.8.0; import "@openzeppelin/contracts/token/ERC721/ERC721.sol"; import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Enumerable.sol"; import "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol"; contract ERC721MerkleDrop is ERC721Enumerable { bytes32 immutable public root; // in the constructor you have to pass merkleroot from previous step. mapping(address => uint256) private redeemedTokens;     // for example: // "0x52eaa2b89664fed7b22e3047004bbc536fa6e001046063cc47a5c8420722f8d1"     constructor(string memory name, string memory symbol, bytes32 merkleroot) ERC721(name, symbol) { root = merkleroot; } // Redeemer have to call this function with proper amount of NFTs and proof // for example: // 1, "0xa1e7d0c73d9389a48d624133917ba9948dcaa66e0fcfe6f123ac3cb16b787cdd"   function redeem(uint256 amount, bytes32[] calldata proof) external { require(_verify(_leaf(msg.sender, amount), proof), "Invalid merkle proof"); require(redeemedTokens[msg.sender] < amount, "Wallet already redeemed"); uint256 currentSupply = totalSupply(); for (uint256 i = 1; i <= amount; i++) { _safeMint(msg.sender, currentSupply + i); } // keep track of redeemed tokens // otherwise same proof might be used twice!!1one         redeemedTokens[msg.sender] = amount; } function _leaf(address account, uint256 amount) internal pure returns(bytes32) { return keccak256(abi.encodePacked(account, amount)); } function _verify(bytes32 leaf, bytes32[] memory proof) internal view returns(bool) { return MerkleProof.verify(proof, root, leaf); } }

Passing wrong proof (from a different tree or just a random string) would result in an error. If some attacker would change the structure of the tree (by inserting their address or manipulating amounts) the Merkle root would be entirely different.

The smart contract just validates the generated proofs and it's not that memory extensive as Solidity example mentioned earlier. Proofs are long and random enough to not be easily guessed.

Pros:

  • Cheaper whitelisting and minting

Cons:

  • Changes to the whitelist require rebuilding Merkle tree and updating the root on the contract
  • Delivery of Merkle proofs to list participants is necessary

Lazy minting with vouchers

Another whitelisting solution is using the concept of securely signed vouchers. The procedure is almost the same as with Merkle Tree. However, before the minting, you need to generate vouchers off-chain and sign them with your private key:

-- CODE language-js -- const { ethers } = require("hardhat"); const SIGNING_DOMAIN = "YourCollection"; const SIGNATURE_VERSION = "1"; const generateVoucher = async (signer, tokenId, contractAddr, chainId, recipient) => { const voucher = { tokenId, recipient } const domain = { name: SIGNING_DOMAIN, version: SIGNATURE_VERSION, chainId, verifyingContract: contractAddr } const types = { NFTVoucher: [{ name: "tokenId", type: "uint256" }, { name: "recipient", type: "address" }] } const signature = await signer._signTypedData(domain, types, voucher) return { ...voucher, signature, } } const contractAddr = "YOUR CONTRACT ADDR"; const signer = new ethers.Wallet("private key of signer"); // keep it secure const main = async () => { const voucher = await generateVoucher(signer, tokenId, contractAddr, 1, recipientAddr); console.log(JSON.stringify(voucher)); } main()

The voucher may contain information about which address can use it and which token id precisely can be minted. Of course, it’s possible just to mint any available id. In such a scenario, you’d need to refactor a bit structure of the voucher. Need to take payment for minting even for whitelisted guys? No problem… just make the redemption function payable and validate the amount sent to it.

The last step is to add logic to the contract that may validate if the voucher was produced by the minter address:

-- CODE language-js -- //SPDX-License-Identifier: MIT // Code customizations by chmaro.eth pragma solidity ^ 0.8.4; pragma abicoder v2; // required to accept structs as function parameters import "@openzeppelin/contracts/access/AccessControl.sol"; import "@openzeppelin/contracts/access/Ownable.sol"; import "@openzeppelin/contracts/token/ERC721/ERC721.sol"; import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol"; import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; import "@openzeppelin/contracts/utils/cryptography/draft-EIP712.sol"; contract YourDamnGoodNft is ERC721URIStorage, EIP712, AccessControl, Ownable { bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE"); string private constant SIGNING_DOMAIN = "Your contract"; string private constant SIGNATURE_VERSION = "1"; mapping(address => uint) private whitelistedAddrs; string public baseUri; string public contractURI; bool public vouchersEnabled = true; constructor(address payable minter) ERC721("Your contract", "Symbol") EIP712(SIGNING_DOMAIN, SIGNATURE_VERSION) { _setupRole(MINTER_ROLE, minter); } /// @notice Represents an un-minted NFT, which has not yet been recorded into the blockchain. /// @noticeA signed voucher can be redeemed for a real NFT using the redeem function.   struct NFTVoucher { /// @notice The id of the token to be redeemed. Must be unique - /// if another token with this ID already exists, the redeem function will revert.     uint256 tokenId; address recipient; /// @notice the EIP-712 signature of all other fields in the NFTVoucher struct. /// @notice For a voucher to be valid, it must be signed by an account with the MINTER_ROLE.     bytes signature; } /// @notice Redeems an NFTVoucher for an actual NFT, creating it in the process.   /// @param redeemer The address of the account which will receive the NFT upon success.   /// @param voucher A signed NFTVoucher that describes the NFT to be redeemed.   function redeem(address redeemer, NFTVoucher calldata voucher) public returns(uint256) { // make sure signature is valid and get the address of the signer     address signer = _verify(voucher); // make sure that the signer is authorized to mint NFTs     require(hasRole(MINTER_ROLE, signer), "Signature invalid or unauthorized"); require(voucher.recipient == msg.sender, "Voucher is for different caller"); // first assign the token to the signer, to establish provenance on-chain     _mint(redeemer, voucher.tokenId); return voucher.tokenId; } /// @notice Returns a hash of the given NFTVoucher, prepared using EIP712 typed data hashing rules.   /// @param voucher An NFTVoucher to hash.   function _hash(NFTVoucher calldata voucher) internal view returns(bytes32) { return _hashTypedDataV4(keccak256(abi.encode( keccak256("NFTVoucher(uint256 tokenId,address recipient)"), voucher.tokenId, voucher.recipient))); } /// @notice Returns the chain id of the current blockchain.   /// @dev This is used to workaround an issue with ganache returning different values from the on-chain chainid() /// function and   ///  the eth_chainId RPC method. See https://github.com/protocol/nft-website/issues/121 for context.   function getChainID() external view returns(uint256) { uint256 id; assembly { id: = chainid() } return id; } /// @notice Verifies the signature for a given NFTVoucher, returning the address of the signer.   /// @dev Will revert if the signature is invalid. Does not verify that the signer is authorized to mint NFTs.   /// @param voucher An NFTVoucher describing an unminted NFT. function _verify(NFTVoucher calldata voucher) internal view returns(address) { bytes32 digest = _hash(voucher); return ECDSA.recover(digest, voucher.signature); } function supportsInterface(bytes4 interfaceId) public view virtual override(AccessControl, ERC721) returns(bool) { return ERC721.supportsInterface(interfaceId) || AccessControl.supportsInterface(interfaceId); } }

I personally prefer above technique than merkle proofs. I think it’s more flexible and still cheap.

Pros:

  • Cheaper minting
  • Flexible, you may produce many vouchers over time

Cons:

  • Delivery of vouchers to participants is necessary

That’s pretty much it. If you understand how & when using the above techniques you are ready for building whitelists effectively.

Good luck with your upcoming drops. WAGMI