Published on

January 15, 2024

Securing NFT Drops: Protecting Allowlists with Merkle Trees

The countdown is on, and after dedicating numerous hours to prepare for your NFT drop, you now have a supportive community eagerly anticipating the event. Your marketing efforts are at their peak, and you've compiled a comprehensive list of community members whom you'd like to incentivize with a discounted mint price. The allowlist is now filled with your most enthusiastic supporters. What's the next step? How do you ensure that the NFT’s can be claimed by the approved participants. Well, you need a merkletree.

TL;DR

I am going to place a fork in the road right here. If you just wanna create a merkle root from a list of addresses, try this merkleroot generation tool I created for that exact purpose but understand the following. A Merkle tree for NFT allow lists is a data structure that efficiently verifies membership of specific addresses on the list. It uses cryptographic hash functions to create a tree structure, with leaf nodes representing individual addresses and higher-level nodes containing hashes of their children. This allows for quick and secure validation of whether an address is on the allow list without revealing the entire list or requiring extensive computation. It's a privacy-preserving and scalable solution to manage and validate participant eligibility.

So, what is a merkletree?

A Merkle tree is a data structure designed to verify the presence of specific data without requiring any knowledge about the content it holds. The Merkle tree's design facilitates both efficient and secure verification of data integrity through the utilization of nodes.

Nodes

The tree is comprised of three node types, leaf, parent and root.

In the context of a tree data structure, a "node" is a fundamental element. It is a part of the tree that contains data and links to other nodes. Each node in a tree has a parent node (except for the root node) and zero or more child nodes. The topmost node in a tree is called the "root," and nodes without any children are referred to as "leaves" or "leaf nodes."

In the context of a Merkle tree:

  • Leaf Nodes: These nodes represent the actual addresses, data or transactions.
  • Internal Nodes (Parent Nodes): These nodes represent the hashes of their child nodes.

Nodes serve as building blocks in the tree structure, allowing for the organization and representation of data in a hierarchical manner.

Figure 1. Merkle Tree Structure
Figure 1. Merkle Tree Structure

Why is everything hashed?

Hashing is a process that transforms input data of any size into a fixed-size string of characters. The goal of hashing is to generate a unique output (hash) for each unique input, in our case a wallet address. Even a small change in the input should produce a significantly different hash.

Here's a basic overview of how hashing works:

  1. Input Data:
    • Any piece of data, whether it's an address, a file, or a message, can be the input for hashing.
  2. Hash Function:
    • A hash function is a mathematical algorithm that takes input data and produces a fixed-size string of characters, which is the hash value. The hash function should be deterministic (producing the same output for the same input) and quick to compute.
  3. Fixed Size Output:
    • The hash function always produces a fixed-size output, regardless of the size of the input data. Our hash length will be 256 bits.
  4. Deterministic:
    • The same input will always produce the same hash value. This property is crucial for consistency and reliability.
  5. Collision Resistance:
    • A good hash function minimizes the likelihood of two different inputs producing the same hash value. This property is known as collision resistance.
  6. Avalanche Effect:
    • A small change in the input should result in a significantly different hash value. This property is called the avalanche effect.
  7. Irreversibility:
    • Hashing is a one-way process. Given a hash value, it should be computationally infeasible to reverse the process and obtain the original input.

In summary, hashing is a fundamental concept in cryptography, providing a way to quickly and reliably represent data in a fixed-size format while maintaining properties like uniqueness, collision resistance, and irreversibility.

Implementation - Frontend Dapp

import { useCallback } from "react";
import { MerkleTree } from "merkletreejs";
import { ethers } from "ethers";

const generateMerkleRoot = async(addresses[]) => {
  const leaves = addresses.map((v) => ethers.utils.keccak256(v));
  const tree = new MerkleTree(leaves, ethers.utils.keccak256, {
    sort: true,
  });
  const merkleRoot = tree.getHexRoot();
  return merkleRoot;
};

Let's get into the exciting part! It's quite straightforward. The primary dependency is merkletreejs. The given function takes an array of addresses, allowing us to generate the entire tree but it’s the merkle root that we require initially.

Figure 2. Logging A Merkle Tree
Figure 2. Logging A Merkle Tree

Figure 2. Merkle Tree Structure - React

Here are the results when I logged out the tree using an array of 4 addresses. The visual presentation is beneficial, grouping all nodes in a clear structure. Beginning from the top, we encounter leaf nodes, followed by parent nodes, and ultimately, the root node. Once the root node is obtained, it can be stored in a smart contract. By referencing active minters' addresses against it, we can efficiently verify their authorization.

The minting function


import { MerkleTree } from 'merkletreejs';
import { ethers } from 'ethers';

function whitelistMint(address){
  // Replace 'contractAllowlist()' with the actual method to fetch your contract's allowlist
  const leaves = contractAllowlist().map((addr) =>
    ethers.utils.keccak256(addr)
  );

  const tree = new MerkleTree(leaves, ethers.utils.keccak256, {
    sort: true,
  });

  // Make sure 'address' is defined before proceeding
  if (address) {
    const leaf = ethers.utils.keccak256(address.toLowerCase());
    const proof = tree.getHexProof(leaf);

    // Now you can use 'proof' for verification or any other logic
    // For example, if proof.length == 0, show error message
  } else {
    console.error('Address is undefined. Cannot generate Merkle proof.');
  }
}

// Example usage:
const userAddress = '0x123abc...'; // Replace with the actual user's address
whitelistMint(userAddress);

In this example, the whitelistMint function is designed to generate a Merkle proof for a specified address, confirming its inclusion in a Merkle tree created from an allowlist. The process involves fetching addresses from the contract's allowlist, hashing them to form leaves, constructing a merkle tree, and finally producing a proof for a given user's address. The resulting proof can be used to verify the user's authorization or eligibility for minting the NFT on your smart contract.

What is a merkle proof

Figure 2. Logging A Merkle Proof
Figure 2. Logging A Merkle Proof

The provided array is a merkle proof for a specific address in the merkle tree. Each element in the array represents a hash value at a particular level of the tree. Let's break down the components:

  1. Leaf Node Hash:
    • The first element in the array ('0x0a81e8b233441d9af786174c4f85bc0b2175d7dcce2ba9e0e297326fba55e7dc') is the hash of the specific address you want to prove the inclusion of. This is the "leaf hash."
  2. Parent Node Hash:
    • The second element in the array ('0x761446da6329df3bcf4d4c73338a138633522fb9fbf7c2668f13c6eea2a07270') is the hash of the parent node at the same level in the merkle tree.

The proof consists of the hashes of the Internal Nodes (Parent Nodes) encountered on the path to the root.

To verify the inclusion of the leaf node in the merkle tree, you would use these hashes to hash your way up to the root. If the final computed root hash matches the known root hash, the inclusion is confirmed.

Implementation - Smart Contract

import "@openzeppelin/contracts/cryptography/MerkleProof.sol";

contract YourNFTContract {
    // Assume that you have a WhitelistConfig struct defined
    struct WhitelistConfig {
        bytes32 merkleRoot;
        // Other fields if needed
    }

    error NotWhitelisted();

    function whitelistMint(address userAddress, bytes32[] memory merkleProof, WhitelistConfig memory whitelistConfig) public {
        // Hash the user's address to match with the Merkle proof
        bytes32 leaf = keccak256(abi.encodePacked(userAddress));

        // Verify the Merkle proof using OpenZeppelin's MerkleProof contract
        if (!MerkleProof.verify(merkleProof, whitelistConfig.merkleRoot, leaf)) {
            revert NotWhitelisted();
        }

        // Continue with the minting process for whitelisted users
        // Add your minting logic here
    }
}

It is important to remember that users can interact with your contract directly so the same merkle root authorization needs to be in place here.

In the example above, the Solidity code presents a whitelist mint function within an NFT contract. The function, whitelistMint, takes the user's address, a merkle proof, and whitelist configuration as inputs. It verifies the user's inclusion in the whitelist by hashing their address, comparing it with the provided merkle proof using OpenZeppelin's MerkleProof contract, and reverting the transaction if the verification fails. Following successful verification, the code proceeds with the minting process for whitelisted users.

Live Demo

If you're looking to generate merkle roots for your application, I strongly recommend trying out a tool I've created specifically for this purpose. You can access it here.

Conclusion

Well, if you've reached this point, a sincere salute to you! The content provided covered a substantial amount, and I trust you've found value in the information. If you have any further questions or need clarification, feel free to reach out. Happy exploring!

This post was updated on

June 20, 2024.