Development of Merkle-Tree Whitelist for NFT
A collection of 10,000 tokens with 3,000 whitelist addresses. Storing the whitelist in an on-chain mapping—that's 3,000 SSTORE operations during setup, roughly 0.5-0.8 ETH in gas just for the setup. Merkle Tree solves this in one transaction: root hash in the contract, proof for each address off-chain. Gas for verifying one address during minting—around 3-5k instead of full SLOAD from the mapping.
How Merkle Proof Verification Works
Building the Tree
Tree leaves are keccak256 hashes of addresses (sometimes with additional data: keccak256(abi.encodePacked(address, maxMintAmount))). The tree is built bottom-up: hashes of adjacent leaves are combined and hashed. The root—a single bytes32 stored in the contract.
import { MerkleTree } from 'merkletreejs'
import { keccak256, encodePacked } from 'viem'
const leaves = whitelist.map(addr =>
keccak256(encodePacked(['address'], [addr]))
)
const tree = new MerkleTree(leaves, keccak256, { sortPairs: true })
const root = tree.getHexRoot() // → bytes32 for the contract
sortPairs: true—a critical parameter. It ensures deterministic tree building regardless of leaf order. Without it, the same whitelist produces different roots with different address order.
Verification in the Contract
import "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol";
bytes32 public merkleRoot;
function mint(uint256 amount, bytes32[] calldata proof) external payable {
bytes32 leaf = keccak256(abi.encodePacked(msg.sender));
require(MerkleProof.verify(proof, merkleRoot, leaf), "Invalid proof");
// ... mint logic
}
MerkleProof.verify() from OpenZeppelin—the standard implementation with O(log n) complexity. For 3,000 addresses—a proof of 12 hashes (log2(3000) ≈ 12). For 100,000 addresses—17 hashes. Verification gas cost grows slowly.
Double-Leaf Vulnerability and Protection
A classic Merkle Tree problem in smart contracts: if a leaf isn't unique, one proof can verify multiple leaves. Attack: create an address whose keccak256 matches the concatenation of two leaves at the next level.
Protection in OpenZeppelin MerkleProof: the library checks that leaf != internal_node, meaning a leaf is never accepted as an intermediate node. This is built-in starting with OZ 4.7. If you're using an older version or custom implementation—add an explicit check.
Additional protection: hash the leaf twice keccak256(keccak256(abi.encodePacked(addr))). This makes leaf matching an internal node practically impossible.
Advanced Whitelist: Tiers and Amounts
Simple whitelist—just check "address exists / doesn't exist". For multi-level whitelists (tier 1: 2 NFT, tier 2: 1 NFT)—include data in the leaf:
bytes32 leaf = keccak256(abi.encodePacked(msg.sender, maxAmount));
require(MerkleProof.verify(proof, merkleRoot, leaf), "Invalid proof");
require(amount <= maxAmount, "Exceeds allocation");
Now the proof verifies not just the address but the maximum quantity for that address. One tree, one root, different allocations.
Protection Against Double-Mint
Merkle Proof only verifies the right to mint—doesn't prevent repeat mints. Track used allocations separately:
mapping(address => uint256) public mintedAmount;
function mint(uint256 amount, uint256 maxAmount, bytes32[] calldata proof) external {
require(mintedAmount[msg.sender] + amount <= maxAmount, "Exceeds allocation");
mintedAmount[msg.sender] += amount;
// ...
}
mintedAmount uses SSTORE only on the first mint for each user—this is O(unique_minters) storage, not O(whitelist_size).
Off-chain Distribution of Proofs
Three approaches for how the user gets their proof:
JSON file in IPFS/CDN. { "0xABC...": ["0x...", "0x..."] }—a static dictionary address → proof. Generated once when the tree is created, published to CDN. User makes a GET request with their address, gets the proof. Fast, cheap, no backend.
Backend API. GET /api/whitelist/proof?address=0xABC. Lets you add addresses without rebuilding the entire JSON. But requires either recalculating the tree on additions or storing all leaves in a DB to dynamically build the proof. If the whitelist changes after root publication—you need to update the root in the contract (if the contract allows).
On-chain Events. If adding to the whitelist happens via smart contract (e.g., through Premint or NFT proof-of-hold)—events can be indexed through The Graph and the tree built dynamically.
Updating Whitelist After Deployment
If the contract allows changing merkleRoot (via onlyOwner), the whitelist can be updated without redeployment. Scenario: main whitelist + last-minute additions. Build a new tree with additions, update the root via setMerkleRoot().
Important: after changing the root, old proofs stop working. Users with cached proofs will get reverts. Communication about the update is necessary.
Development Process
Development (2-3 days). Tree generator (Node.js script), contract with verification, API or static JSON for proofs. Foundry tests: verify correct proofs pass, incorrect ones fail, double-mint is blocked.
Frontend Integration. wagmi for calling the mint function, merkletreejs for client-side proof verification before sending the transaction (UX optimization).
Timeline Estimates
Basic Merkle whitelist implementation—2-3 days. With tiers, API for proofs, and frontend integration—up to 5 days.
Cost is calculated after clarifying whitelist size and tier structure.







