NFT Allowlist Implementation with Merkle Trees: A Complete Engineering Guide
Merkle tree allowlists are the gas-efficient standard for NFT whitelist minting. Learn how to generate the tree, store the root, and verify proofs on-chain.
You have a list of 5,000 wallet addresses that earned early access to your mint. You need to enforce that only those addresses can participate in the presale phase, and you need to do it without storing 5,000 addresses in contract storage. Storing every address on-chain would cost an enormous amount in deployment gas. A Merkle tree lets you commit to the entire list with a single 32-byte hash and verify membership proofs for individual addresses at mint time.
This is the production approach used by the large majority of serious NFT collections. Here is how it works end to end.
How Merkle Trees Work for Allowlists
A Merkle tree is a binary hash tree. Each leaf node is a hash of one piece of data — in this case, an allowlisted wallet address. Parent nodes are hashes of their two children. The tree's root is a single hash that represents the entire dataset.
To prove a specific address is in the list, you provide the address and a proof — a sequence of sibling hashes up the tree to the root. The contract recomputes the root from the address and the proof. If the recomputed root matches the stored root, the address is in the list.
This verification is done in a single transaction, requires only the proof (log N hashes for N addresses), and the storage cost is fixed: one bytes32 root value in your contract.
Step 1: Generate the Merkle Tree Off-Chain
Use a library like merkletreejs in JavaScript or Python's merkletools:
const { MerkleTree } = require('merkletreejs');
const keccak256 = require('keccak256');
const addresses = [
'0xAddress1',
'0xAddress2',
// ... all 5000 addresses
];
const leaves = addresses.map(addr =>
keccak256(addr.toLowerCase())
);
const tree = new MerkleTree(leaves, keccak256, { sortPairs: true });
const root = tree.getHexRoot();
// Generate proof for a specific address
const proof = tree.getHexProof(keccak256('0xAddress1'.toLowerCase()));The sortPairs: true option is important. OpenZeppelin's Solidity MerkleProof library expects sorted pairs. Using sortPairs on the JavaScript side ensures compatibility.
Step 2: Store the Root in Your Contract
bytes32 public merkleRoot;
constructor(bytes32 _merkleRoot) ERC721A("MyNFT", "MNFT") {
merkleRoot = _merkleRoot;
}
function setMerkleRoot(bytes32 _merkleRoot) external onlyOwner {
merkleRoot = _merkleRoot;
}Including a setMerkleRoot function is practical — if you need to add or remove addresses before mint, you can update the root without redeploying. Lock this function or remove it after mint opens if you want the list to be immutable.
Step 3: Verify Proofs at Mint Time
import "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol";
function allowlistMint(uint256 quantity, bytes32[] calldata proof) external payable {
require(allowlistActive, "Allowlist not active");
require(
MerkleProof.verify(proof, merkleRoot, keccak256(abi.encodePacked(msg.sender))),
"Not on allowlist"
);
require(_numberMinted(msg.sender) + quantity <= MAX_PER_ALLOWLIST_WALLET, "Exceeds allowlist allocation");
require(msg.value >= quantity * ALLOWLIST_PRICE, "Insufficient payment");
_mint(msg.sender, quantity);
}The proof comes from your frontend. When a user connects their wallet, your frontend:
- Checks if the connected address is in your allowlist database
- Generates the Merkle proof using the same tree you generated off-chain
- Passes the proof to the
allowlistMintcall
Step 4: Per-Address Mint Allocation
The example above uses a fixed max-per-wallet for the allowlist. Some projects need variable allocations — different addresses have different mint limits. You can encode this in the Merkle leaf:
// Encode address + allocation together
const leaf = keccak256(
ethers.utils.solidityPack(['address', 'uint256'], [address, allocation])
);And verify accordingly in Solidity:
function allowlistMint(uint256 quantity, uint256 allocation, bytes32[] calldata proof) external payable {
bytes32 leaf = keccak256(abi.encodePacked(msg.sender, allocation));
require(MerkleProof.verify(proof, merkleRoot, leaf), "Invalid proof");
require(_numberMinted(msg.sender) + quantity <= allocation, "Exceeds your allocation");
// ...
}This is more complex but gives you fine-grained control per address.
Mint Phase Management
A production contract typically has multiple mint phases:
- Allowlist phase: Restricted to Merkle-verified addresses. Lower price or higher allocation.
- Public mint phase: Open to all. Different price, different per-wallet cap.
Manage this with an enum or a boolean:
enum MintPhase { Closed, Allowlist, Public }
MintPhase public phase;
modifier onlyDuringPhase(MintPhase _phase) {
require(phase == _phase, "Wrong mint phase");
_;
}Each mint function is gated by onlyDuringPhase. Transitioning between phases is an owner-only action.
Common Mistakes in Allowlist Implementation
- Not using
sortPairs: truewhen generating the tree. OpenZeppelin's Solidity library uses sorted pairs. Mismatched pair ordering will cause valid proofs to fail. - Encoding the leaf incorrectly. The leaf hash in JavaScript and in Solidity must be identical. Use
abi.encodePackedin Solidity andethers.utils.solidityPackin JavaScript. - Exposing the full address list publicly. The Merkle root is public; the proofs can be selectively revealed. You do not need to publish every address in the tree — only the proof for the connecting wallet needs to be served to the frontend.
- Not capping per-wallet mints separately for allowlist and public phases. Use
_numberMintedor a separate mapping to track allowlist mints independently from public mints.
Merkle tree allowlists are a well-established pattern, but the leaf encoding and proof generation consistency between off-chain and on-chain code is where implementations break. If you need this implemented and tested correctly, the Clixo team builds NFT mint contracts with allowlist mechanics as a standard offering.