OpenZeppelin's MerkleProof Utility: A Deep Dive
How to verify that a piece of data belongs to a predefined set without storing the entire set on-chain?

OpenZeppelin's MerkleProof utility is a powerful tool for efficiently verifying that a piece of data belongs to a predefined set without storing the entire set on-chain. This is particularly useful for allowlists, airdrops, or any scenario where you need to verify membership without incurring excessive gas costs.
How MerkleProof Works
The MerkleProof utility implements functions to verify that a leaf node is part of a Merkle tree by checking its inclusion proof.
Here's the core MerkleProof implementation from OpenZeppelin:
library MerkleProof {
/**
* @dev Returns true if a `leaf` can be proved to be a part of a Merkle tree
* defined by `root`. For this, a `proof` must be provided, containing
* sibling hashes on the branch from the leaf to the root of the tree. Each
* pair of leaves and each pair of pre-images are assumed to be sorted.
*/
function verify(
bytes32[] memory proof,
bytes32 root,
bytes32 leaf
) internal pure returns (bool) {
return processProof(proof, leaf) == root;
}
/**
* @dev Returns the rebuilt hash obtained by traversing a Merkle tree up
* from `leaf` using `proof`. A `proof` is valid if and only if the rebuilt
* hash matches the root of the tree. When processing the proof, the pairs
* of leafs & nodes are assumed to be sorted.
*/
function processProof(bytes32[] memory proof, bytes32 leaf) internal pure returns (bytes32) {
bytes32 computedHash = leaf;
for (uint256 i = 0; i < proof.length; i++) {
bytes32 proofElement = proof[i];
if (computedHash <= proofElement) {
// Hash(current computed hash + current element of the proof)
computedHash = keccak256(abi.encodePacked(computedHash, proofElement));
} else {
// Hash(current element of the proof + current computed hash)
computedHash = keccak256(abi.encodePacked(proofElement, computedHash));
}
}
return computedHash;
}
}
Creating an NFT Allowlist with MerkleProof
Let's implement an NFT allowlist using Merkle trees in a step-by-step process:
Step 1: Generate the Merkle Tree Off-chain
First, you'll need to generate the Merkle tree off-chain.
Here's how you'd do it using JavaScript with the keccak256
and merkletreejs
libraries:
const { MerkleTree } = require('merkletreejs');
const keccak256 = require('keccak256');
const { ethers } = require('ethers');
// Sample allowlist of addresses
const allowlist = [
'0x1234567890123456789012345678901234567890',
'0xabcdefabcdefabcdefabcdefabcdefabcdefabcd',
'0x9876543210987654321098765432109876543210',
// Add more addresses as needed
];
// Hash each address to get the leaf nodes
const leaves = allowlist.map(address =>
// We hash address + position to prevent "second preimage attacks"
keccak256(ethers.utils.solidityPack(['address'], [address]))
);
// Create the Merkle tree
const merkleTree = new MerkleTree(leaves, keccak256, { sortPairs: true });
// Get the Merkle root - this is what you'll store in your smart contract
const merkleRoot = merkleTree.getHexRoot();
console.log('Merkle Root:', merkleRoot);
// For each address, generate its proof
// Store these proofs in a database or distribute them to users
allowlist.forEach((address) => {
const leaf = keccak256(ethers.utils.solidityPack(['address'], [address]));
const proof = merkleTree.getHexProof(leaf);
console.log(`Address: ${address}, Proof: ${JSON.stringify(proof)}`);
});
Step 2: Implement the Allowlist in Your NFT Contract
Now, let's create the NFT contract in Solidity with the Merkle-based allowlist:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.9;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol";
contract AllowlistNFT is ERC721, Ownable {
// Merkle root of the allowlist
bytes32 public merkleRoot;
// Price for allowlist mint
uint256 public allowlistPrice = 0.05 ether;
// Price for public mint
uint256 public publicPrice = 0.08 ether;
// Track which addresses have already claimed their allowlist mint
mapping(address => bool) public allowlistClaimed;
// Total supply cap
uint256 public immutable MAX_SUPPLY = 10000;
// Current token ID (starts at 0)
uint256 private _currentTokenId;
// Sale state
enum SaleState { Inactive, Allowlist, Public }
SaleState public saleState = SaleState.Inactive;
constructor(bytes32 _merkleRoot) ERC721("AllowlistNFT", "ALNFT") Ownable(msg.sender) {
merkleRoot = _merkleRoot;
}
// Update the merkle root (in case you need to update the allowlist)
function setMerkleRoot(bytes32 _merkleRoot) external onlyOwner {
merkleRoot = _merkleRoot;
}
// Set the sale state
function setSaleState(SaleState _saleState) external onlyOwner {
saleState = _saleState;
}
// Allowlist mint function
function allowlistMint(bytes32[] calldata merkleProof) external payable {
// Check if allowlist sale is active
require(saleState == SaleState.Allowlist, "Allowlist sale not active");
// Check if this address has already claimed
require(!allowlistClaimed[msg.sender], "Allowlist mint already claimed");
// Verify the merkle proof
bytes32 leaf = keccak256(abi.encodePacked(msg.sender));
require(MerkleProof.verify(merkleProof, merkleRoot, leaf), "Invalid proof");
// Check payment
require(msg.value >= allowlistPrice, "Insufficient payment");
// Mark as claimed
allowlistClaimed[msg.sender] = true;
// Mint the NFT
_safeMint(msg.sender, _currentTokenId);
_currentTokenId++;
}
// Public mint function
function publicMint() external payable {
// Check if public sale is active
require(saleState == SaleState.Public, "Public sale not active");
// Check payment
require(msg.value >= publicPrice, "Insufficient payment");
// Check max supply
require(_currentTokenId < MAX_SUPPLY, "Max supply reached");
// Mint the NFT
_safeMint(msg.sender, _currentTokenId);
_currentTokenId++;
}
// Withdraw contract balance
function withdraw() external onlyOwner {
(bool success, ) = owner().call{value: address(this).balance}("");
require(success, "Withdrawal failed");
}
}
Step 3: Deploy and Use the Contract
- Deploy the contract with the Merkle root generated in Step 1
- Distribute the Merkle proofs to the allowlisted addresses
- When users want to mint, they call
allowlistMint
with their proof
Step 4: Front-end Integration
Here's how users would interact with this contract from a front-end:
// Front-end code to mint using the allowlist
async function allowlistMint(merkleProof) {
// Connect to the contract
const contract = new ethers.Contract(contractAddress, contractABI, signer);
// Call the allowlistMint function with the provided proof
const tx = await contract.allowlistMint(merkleProof, {
value: ethers.utils.parseEther("0.05")
});
// Wait for transaction confirmation
await tx.wait();
console.log("Successfully minted from allowlist!");
}
// When the mint button is clicked:
mintButton.addEventListener('click', async () => {
// The user's proof would typically be fetched from your API or database
// based on their connected wallet address
const userProof = await fetchMerkleProofForUser(userAddress);
if (userProof) {
await allowlistMint(userProof);
} else {
alert("Your address is not on the allowlist or proof couldn't be retrieved.");
}
});
Advanced Use Cases
1. Multi-tier Allowlist
You can create different tiers with different prices or allocation amounts:
// Store the leaf data with additional information
function allowlistMint(bytes32[] calldata merkleProof, uint8 tier, uint256 maxMintAmount) external payable {
// Create the leaf with address, tier, and maxMintAmount
bytes32 leaf = keccak256(abi.encodePacked(msg.sender, tier, maxMintAmount));
require(MerkleProof.verify(merkleProof, merkleRoot, leaf), "Invalid proof");
// Implement tier-specific logic
if (tier == 1) {
require(msg.value >= tier1Price * maxMintAmount, "Insufficient payment");
} else if (tier == 2) {
require(msg.value >= tier2Price * maxMintAmount, "Insufficient payment");
}
// Continue with minting logic
}
2. Allowlist with Different Mint Limits
Encode the maximum allowed mint count for each address:
// Track per-address mint counts
mapping(address => uint256) public mintCount;
function allowlistMint(bytes32[] calldata merkleProof, uint256 maxAllowedMints, uint256 quantity) external payable {
// Verify the address is on the allowlist with the specified max amount
bytes32 leaf = keccak256(abi.encodePacked(msg.sender, maxAllowedMints));
require(MerkleProof.verify(merkleProof, merkleRoot, leaf), "Invalid proof");
// Check if the user has remaining mints
require(mintCount[msg.sender] + quantity <= maxAllowedMints, "Exceeds allowlist allocation");
// Update the mint count
mintCount[msg.sender] += quantity;
// Mint logic
}
Benefits of Using MerkleProof for Allowlists
- Gas Efficiency: Store just one Merkle root hash on-chain instead of thousands of addresses
- Flexibility: Easily update the allowlist by changing the Merkle root
- Security: Cryptographically secure verification of allowlist membership
- Privacy: The full list doesn't need to be public (though it's not hidden either)
- Extensibility: Can include additional data like tier information or allocation amounts
Potential Challenges
- Off-chain Infrastructure: You need to generate and store the Merkle proofs
- User Experience: Users need to provide their proof when minting
- Complexity: More complex than a simple address → bool mapping
By using OpenZeppelin's MerkleProof utility, you can create gas-efficient allowlists that scale to thousands or even millions of addresses without prohibitive on-chain storage costs.