Advanced NFT Contract Analysis

This article breaks down an advanced ERC-721 NFT contract that implements multiple Solidity design patterns. It features a state machine for sale phases, Merkle tree verification for allowlists, commit-reveal pattern for fair minting, batch operations, and a pull payment system.
Assignment:
- Implement a merkle tree airdrop where addresses in the merkle tree are allowed to mint once.
- Measure the gas cost of using a mapping to track if an address already minted vs tracking each address with a bit in a bitmap. Hint: the merkle leaf should be the hash of the address and its index in the bitmap. Use the bitmaps from OpenZeppelin
- Use commit reveal to allocate NFT ids randomly. The reveal should be 10 blocks ahead of the commit. You can look at cool cats NFT to see how this is done. They use chainlink, but you should use commit-reveal.
- Add multicall to the NFT so people can transfer several NFTs in one transaction (make sure people can't abuse minting!)
- The NFT should use a state machine to determine if it is mints can happen, the presale is active, or the public sale is active, or the supply has run out. Require statements should only depend on the state (except when checking input validity)
- Designated address should be able to withdraw funds using the pull pattern. You should be able to withdraw to an arbitrary number of contributors
Contract Imports and Inheritance
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
// Import standard ERC721 NFT functionality
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
// Import BitMaps for efficient boolean tracking (e.g., Merkle claim tracking)
import "@openzeppelin/contracts/utils/structs/BitMaps.sol";
// Import MerkleProof library for Merkle tree verification
import "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol";
// Import Ownable for access control (onlyOwner)
import "@openzeppelin/contracts/access/Ownable.sol";
/// @title Advanced NFT with Merkle airdrop, commit-reveal minting, multicall, and pull payments
contract AdvancedNFT is ERC721, Ownable {
using BitMaps for BitMaps.BitMap;
// Contract implementation follows...
}The contract builds on OpenZeppelin's battle-tested implementations:
- ERC721: The base NFT standard providing core token functionality
- BitMaps: Gas-efficient data structure for tracking boolean values (claimed status)
- MerkleProof: Utilities for verifying cryptographic proofs for whitelists
- Ownable: Access control pattern restricting admin functions
State Machine Pattern
/// ========== Sale State Machine ==========
/// @notice Enum representing the different sale phases
enum SaleState { Inactive, Presale, PublicSale, SoldOut }
/// @notice Current state of the sale (default is Inactive)
SaleState public saleState;
/// ========== Sale State Control ==========
/// @notice Set the current sale phase (onlyOwner)
function setSaleState(SaleState _state) external onlyOwner {
saleState = _state;
}The state machine pattern implements distinct operational modes for the contract:
State Machine Benefits
- Phase Control: Creates clear boundaries between contract phases
- Function Guards: Guards functions based on the current state (e.g., minting only during active sales)
- Predictable Flow: Provides a clear progression from presale to public sale to sold out
Merkle Tree Allowlist Pattern
/// ========== Merkle Claim Tracking ==========
/// @notice Merkle root for presale allowlist
bytes32 public merkleRoot;
/// @notice BitMap to track claimed Merkle indices
BitMaps.BitMap private claimed;
/// @notice Set Merkle root for presale whitelist (onlyOwner)
function setMerkleRoot(bytes32 _merkleRoot) external onlyOwner {
merkleRoot = _merkleRoot;
}
/// ========== Merkle Airdrop Claim ==========
/// @notice Claim NFT during presale using Merkle proof
function claim(uint256 index, bytes32[] calldata merkleProof) external {
require(saleState == SaleState.Presale, "Presale inactive");
require(!claimed.get(index), "Already claimed");
bytes32 leaf = keccak256(abi.encodePacked(index, msg.sender));
require(MerkleProof.verify(merkleProof, merkleRoot, leaf), "Invalid proof");
require(_totalSupply < MAX_SUPPLY, "Max supply reached");
claimed.set(index);
uint256 tokenId = nextTokenId();
_safeMint(msg.sender, tokenId);
_totalSupply += 1;
}Merkle trees enable gas-efficient verification of large allowlists without storing every address on-chain:
How the Merkle Verification Works
- Off-chain generation: The contract owner generates a Merkle tree from all whitelisted addresses and their indices
- Root storage: Only the root hash is stored on-chain, saving significant gas
- Verification: Users provide their index and a Merkle proof that their address belongs to the original set
- Claim tracking: The BitMaps structure efficiently tracks which indices have claimed, preventing duplicate claims
Commit-Reveal Pattern
/// ========== Commit-Reveal Random Minting ==========
struct Commit {
uint256 blockNumber;
bytes32 commitHash;
}
mapping(address => Commit) public commits;
mapping(uint256 => bool) public usedTokenIds;
/// @notice Submit hash of secret for future mint (public sale)
function commit(bytes32 commitHash) external {
require(saleState == SaleState.PublicSale, "Public sale inactive");
require(commits[msg.sender].blockNumber == 0, "Already committed");
commits[msg.sender] = Commit(block.number, commitHash);
}
/// @notice Reveal secret and mint random token ID (≥10 blocks later)
function reveal(bytes32 secret) external {
Commit memory c = commits[msg.sender];
require(c.blockNumber != 0, "No commit found");
require(block.number >= c.blockNumber + 10, "Reveal too early");
require(keccak256(abi.encodePacked(secret)) == c.commitHash, "Invalid secret");
// Generate random token ID
bytes32 randomSeed = keccak256(abi.encodePacked(secret, blockhash(c.blockNumber + 10), msg.sender));
uint256 randomId = (uint256(randomSeed) % MAX_SUPPLY) + 1;
require(!usedTokenIds[randomId], "Token ID already used");
require(_totalSupply < MAX_SUPPLY, "Max supply reached");
usedTokenIds[randomId] = true;
_safeMint(msg.sender, randomId);
_totalSupply += 1;
delete commits[msg.sender];
// Auto-close sale if sold out
if (_totalSupply >= MAX_SUPPLY) {
saleState = SaleState.SoldOut;
}
}The commit-reveal pattern creates fair randomness in a blockchain environment:
Commit-Reveal Benefits
- Fair Randomness: Prevents miners/validators from manipulating the outcome
- Two-Phase Process:
- Commit Phase: User submits a hash of their secret without revealing it
- Reveal Phase: After several blocks, user reveals the original secret, proving it matches the commitment
- Unpredictable IDs: Generates random token IDs that can't be predicted in advance
Test Results and Coverage

The contract has been thoroughly tested with four key test cases:
Test Coverage Analysis
- Merkle Claim Test: Verifies the presale claiming mechanism using Merkle proofs, with a gas usage of 139,885
- Commit-Reveal Test: Ensures the commit-reveal minting process works correctly, consuming 124,119 gas
- Batch Transfer Test: Tests the batch transfer functionality with proper ownership checks, using 159,737 gas
- Gas Mapping Comparison: A placeholder for comparing gas usage between different implementation approaches
The gas measurements show that batch operations are the most expensive, followed by Merkle claims and commit-reveal minting. This is expected as batch operations involve multiple state changes and ownership transfers.
Multicall Pattern (Batch Transfers)
/// ========== Batch Transfers ==========
/// @notice Transfer multiple NFTs in a single transaction
function batchTransfer(address[] calldata to, uint256[] calldata tokenIds) external {
require(to.length == tokenIds.length, "Length mismatch");
for (uint256 i = 0; i < to.length; i++) {
safeTransferFrom(msg.sender, to[i], tokenIds[i]);
}
}Batch operations reduce gas costs and improve UX:
- Gas Efficiency: Much cheaper than multiple separate transactions
- Convenience: Simplifies distribution to multiple recipients
- Atomicity: All transfers succeed or all fail, preventing partial state changes
Pull Payment Pattern
/// ========== Pull Payment System ==========
mapping(address => uint256) public pendingWithdrawals;
/// @notice Owner deposits withdrawable ETH for multiple contributors
function depositFunds(address[] calldata contributors, uint256[] calldata amounts) external payable onlyOwner {
require(contributors.length == amounts.length, "Length mismatch");
uint256 total;
for (uint256 i = 0; i < contributors.length; i++) {
pendingWithdrawals[contributors[i]] += amounts[i];
total += amounts[i];
}
require(total <= address(this).balance, "Insufficient contract balance");
}
/// @notice Contributors withdraw their allocated ETH using pull pattern
function withdraw() external {
uint256 amount = pendingWithdrawals[msg.sender];
require(amount > 0, "Nothing to withdraw");
pendingWithdrawals[msg.sender] = 0;
payable(msg.sender).transfer(amount);
}
/// @notice Accept ETH (for deposits/funding)
receive() external payable {}The pull payment pattern is a security best practice for sending funds:
Pull vs Push Payments
Push Payments (Vulnerable)
- Contract sends ETH directly to recipients
- Vulnerable to reentrancy attacks
- Can fail if recipient is a contract without fallback
- Entire transaction fails if one transfer fails
Pull Payments (Secure)
- Recipients withdraw their own funds
- Prevents reentrancy by using checks-effects-interactions
- Each recipient responsible for their own withdrawal
- Failure of one withdrawal doesn't affect others
Supply Management
/// ========== Supply Management ==========
uint256 private _totalSupply;
uint256 public constant MAX_SUPPLY = 10000;
/// @notice Public view of current supply
function totalSupply() public view returns (uint256) {
return _totalSupply;
}
/// @dev Computes the next token ID (starting at 1)
function nextTokenId() private view returns (uint256) {
return _totalSupply + 1;
}Key supply management features:
- Capped Supply: Hard limit of 10,000 NFTs creates scarcity
- Sequential IDs: Unless using the commit-reveal random ID, NFTs are minted with sequential IDs
- Auto State Change: When max supply is reached, contract automatically enters SoldOut state
Security Considerations
Key Security Features
- Access Control: Administrative functions restricted via onlyOwner modifier
- Prevention of Duplicate Claims: BitMaps tracking prevents claiming the same allowlist spot twice
- Checks-Effects-Interactions: Pull payment pattern follows this security pattern to prevent reentrancy
- Randomness Protection: Commit-reveal pattern prevents manipulation of NFT ID randomness
- State Guards: Functions check the contract's state before allowing operations
Conclusion
This NFT contract demonstrates advanced Solidity patterns working together to create a secure, gas-efficient, and feature-rich NFT collection. The combination of state machines, Merkle trees, commit-reveal, batch operations, and pull payments showcases professional smart contract development practices.
Each pattern addresses specific challenges in blockchain development, from ensuring fair distribution with allowlists to preventing various attack vectors. By understanding these patterns, developers can create more secure and efficient smart contracts.