Appearance
L2-10: Merkle NFTs (Merkle Whitelist Minting)
1. Problem
NFT projects often need a whitelist mechanism -- only pre-approved addresses can participate in early minting. The most direct approach is storing the whitelist in a contract mapping, but with 10,000 whitelist addresses and each mapping write costing 20,000 gas (cold SSTORE), the total cost reaches 200,000,000 gas -- completely unaffordable on mainnet.
Merkle Trees offer an elegant solution: compress the entire whitelist (regardless of size) into a single 32-byte Merkle Root stored in the contract. Each user provides their own Merkle Proof (a path from leaf to root) to prove "I am on the whitelist." The verification process requires only O(log2(n)) hash computations -- for a whitelist of 1 million addresses, each proof needs only 20 bytes32 values (640 bytes of calldata).
This challenge (refer to src/level2/MerkleNFT.sol) requires implementing an ERC-721-based NFT contract that uses Merkle Proof verification for whitelist minting, while also supporting mint price, supply cap, duplicate mint protection, and withdrawal functionality.
2. Why
Merkle Trees are a cornerstone technology for Ethereum scalability. From Rollup state root commitments to airdrop claims to NFT whitelists, Merkle proofs are everywhere. Understanding Merkle verification is not just about learning to use MerkleProof.verify() -- it is about understanding the essence of the "off-chain computation + on-chain verification" Ethereum scaling paradigm: compressing O(n) on-chain computation to O(log n) and storage to O(1).
Merkle Tree security depends on the collision resistance of the hash function. The standard implementation uses "double hashing" to defend against second preimage attacks: when concatenating two child nodes, they are sorted in lexicographic order (if computedHash < proof[i], place computedHash first, then proof[i]; otherwise, reverse). Without sorting, an attacker could, under certain conditions, construct a seemingly valid proof that bypasses verification.
MerkleNFT is also a comprehensive exercise that integrates multiple ERC-721 standard features: _safeMint ensures the recipient is an ERC-721-aware address (preventing NFTs from being locked in contracts), hasClaimed prevents duplicate minting, and withdraw provides a revenue extraction interface. Each of these concepts is simple on its own, but together they form a near-production-grade NFT minting system.
3. Solution
Core Architecture
The MerkleNFT contract (refer to src/level2/MerkleNFT.sol) inherits OpenZeppelin's ERC721 and uses the MerkleProof library for proof verification:
Off-chain generation On-chain verification
==================== =====================
Whitelist address list bytes32 immutable MERKLE_ROOT
↓
Generate Merkle Tree bytes32 leaf = keccak256(abi.encodePacked(msg.sender))
↓
Extract proof for each address proof.verify(MERKLE_ROOT, leaf) → true/false
↓ ↓
Frontend passes proof _safeMint(msg.sender, tokenId)
Key Implementation Details
solidity
contract MerkleNFT is ERC721 {
using MerkleProof for bytes32[];
bytes32 public immutable MERKLE_ROOT;
uint256 public constant MINT_PRICE = 0.05 ether;
uint256 public constant MAX_SUPPLY = 100;
uint256 private _tokenIdCounter;
mapping(address => bool) public hasClaimed;
address public owner;
function mint(bytes32[] calldata proof) external payable {
// Three guard checks
if (_tokenIdCounter >= MAX_SUPPLY) revert MaxSupplyReached();
if (hasClaimed[msg.sender]) revert AlreadyClaimed();
if (msg.value < MINT_PRICE) revert InsufficientPayment(msg.value, MINT_PRICE);
// Construct leaf node (standard approach: abi.encodePacked address)
bytes32 leaf = keccak256(abi.encodePacked(msg.sender));
// Verify using MerkleProof library
if (!proof.verify(MERKLE_ROOT, leaf)) revert InvalidProof();
// Prevent replay
hasClaimed[msg.sender] = true;
// Mint
uint256 tokenId = _tokenIdCounter;
_tokenIdCounter++;
_safeMint(msg.sender, tokenId);
emit Minted(msg.sender, tokenId);
}
function withdraw() external {
if (msg.sender != owner) revert Unauthorized();
(bool success,) = owner.call{value: address(this).balance}("");
if (!success) revert WithdrawFailed();
}
}
Core of Merkle Proof Verification (OpenZeppelin MerkleProof Library Implementation)
solidity
function verify(bytes32[] calldata proof, bytes32 root, bytes32 leaf) internal pure returns (bool) {
bytes32 computedHash = leaf;
for (uint256 i = 0; i < proof.length; i++) {
// Sort sibling nodes in lexicographic order to prevent second preimage attacks
if (computedHash < proof[i]) {
computedHash = keccak256(abi.encodePacked(computedHash, proof[i]));
} else {
computedHash = keccak256(abi.encodePacked(proof[i], computedHash));
}
}
return computedHash == root;
}
The lexicographic ordering (the computedHash < proof[i] comparison) is key to Merkle proof security. Without sorting, an attacker could use internal nodes as "leaves" to forge proofs (second preimage attack).
Off-Chain Merkle Tree Generation (JavaScript / ethers.js)
javascript
const { MerkleTree } = require('merkletreejs');
const keccak256 = require('keccak256');
function generateMerkleTree(whitelistAddresses) {
// 1. Generate leaf hash for each address
const leaves = whitelistAddresses.map(addr =>
keccak256(ethers.solidityPacked(['address'], [addr]))
);
// 2. Build Merkle Tree (sortPairs: true matches on-chain lexicographic ordering)
const tree = new MerkleTree(leaves, keccak256, { sortPairs: true });
// 3. Extract root (passed to contract constructor)
const root = tree.getHexRoot();
// 4. Generate proof for each user
const proofs = {};
for (const addr of whitelistAddresses) {
const leaf = keccak256(ethers.solidityPacked(['address'], [addr]));
proofs[addr] = tree.getHexProof(leaf);
}
return { root, proofs };
}
4. Pitfalls Encountered
- Inconsistent leaf construction: using
keccak256(abi.encodePacked(address))off-chain butkeccak256(abi.encode(address))on-chain, causing different leaf hashes -- the proof will never pass - sortPairs configuration mismatch: the
sortPairsoption of the off-chain MerkleTree must match the on-chain lexicographic ordering behavior -- otherwise the proof will fail verification - Duplicate mint bypass: if
hasClaimed[msg.sender] = trueis not set before_safeMint, an attacker could mint multiple times in the same transaction via reentrancy (although_safeMintitself has ERC-721 reentrancy protection, best practice is to mark early) - Merkle Root immutability: if MERKLE_ROOT is made mutable with a setter function, the project team could replace the root mid-mint -- this undermines the fairness of the game
- calldata proof size limit: although Merkle Proofs are theoretically small (32 bytes per level), a proof for an enormous tree (e.g., 2^32 leaves) would be 1024 bytes, still within reasonable range. However, the frontend might include extraneous nodes when constructing the proof
5. Why the Pitfalls Happen
The difference in leaf construction stems from the different encoding methods of abi.encode and abi.encodePacked. abi.encode(address) produces 64 hex characters (with ABI padding), while abi.encodePacked(address) produces 40 characters (no padding). If the off-chain side uses ethers.solidityPacked (corresponding to abi.encodePacked) but the on-chain side uses abi.encode, the encoding results are completely different hashes. Both sides must stay consistent -- this challenge uses abi.encodePacked.
The sortPairs behavior is based on the lexicographic order of hash bytes (unsigned big-endian comparison). If sortPairs is not enabled off-chain (or sorting is turned off), sibling nodes are concatenated in natural order -- while the on-chain MerkleProof library always sorts lexicographically. This results in the same tree's off-chain proof failing on-chain verification.
Duplicate mint bypass exploits transaction atomicity: within the execution context of a single transaction, the hasClaimed state update and _safeMint call happen sequentially. If minting is done before setting state, and _safeMint triggers the recipient's onERC721Received callback (which might call mint again), the guard checks will pass again before the state is updated. Updating hasClaimed state early (check-effects-interactions pattern) is the standard defense.
6. How to Resolve the Pitfalls
Always use the same encoding method for leaf construction: keccak256(abi.encodePacked(msg.sender)). This is the most common leaf construction method in Solidity contracts and is compatible with ethers.js's solidityPacked(['address'], [addr]). If more information needs to be included (such as allowed mint quantity), use a structured leaf: keccak256(abi.encode(address, uint256)).
Ensure sortPairs: true off-chain matches on-chain behavior. merkletreejs defaults to sortPairs: true, and OpenZeppelin's MerkleProof also defaults to lexicographic ordering. If your custom verification logic does not sort, ensure both sides have sorting turned off.
Follow the check-effects-interactions pattern: complete all state updates before any external calls (_safeMint):
solidity
// ✅ Correct order
hasClaimed[msg.sender] = true; // 1. Update state
uint256 tokenId = _tokenIdCounter; // 2. Get and increment
_tokenIdCounter++;
_safeMint(msg.sender, tokenId); // 3. External interaction last
// ❌ Wrong order
_safeMint(msg.sender, tokenId); // 1. External call first
hasClaimed[msg.sender] = true; // 2. State update after (reentrancy vulnerability)
Set MERKLE_ROOT as immutable and assign it in the constructor -- this both saves gas and ensures the immutability of the whitelist. The project team determines the whitelist and generates the root before deploying the contract; it cannot be changed after deployment.
7. Technical Highlights
| Key Point | Description |
|---|---|
| Merkle Root storage | Single bytes32, regardless of whitelist size |
| Verification complexity | O(log2(n)) -- 1 million addresses requires only 20 hashes |
| Leaf construction | keccak256(abi.encodePacked(msg.sender)) consistent with off-chain |
| Second preimage defense | Lexicographic ordering of sibling nodes (computedHash < proof[i]) |
| Merkle Root immutability | Use immutable to prevent tampering mid-mint |
| Duplicate mint protection | Set hasClaimed[msg.sender] = true early (check-effects-interactions) |
| Proof plug-and-play | One-line call to OpenZeppelin MerkleProof.verify() |
| sortPairs | Must be consistent on-chain and off-chain (both sorted or both unsorted) |