← Back to Blog

NFT Mint Security: Dutch Auction, Allowlist, and Fair Launch Vulnerabilities

2026-04-18 nft mint dutch auction allowlist merkle fair launch solidity security 2026

In January 2022, Azuki minted 8,700 tokens at 1 ETH each in under ten minutes — roughly $34 million in a single transaction window. That same window is when the most damaging exploits happen. The contract is live, funds are flowing in, and the team is watching a Discord counter tick up. There is no time to patch.

Mint mechanics are uniquely high-stakes: the contract handles hundreds of ETH within 24 hours, often with no upgrade path and no circuit breaker. Dutch auctions add time-sensitive price logic. Allowlists introduce Merkle proof verification. Fair launches promise equal access. Each of these mechanisms introduces its own vulnerability class.

This post covers six exploitable patterns in NFT mint contracts, with specific vulnerable code and the fixes that prevent them.


1. Dutch Auction Price Not Monotonically Decreasing

A Dutch auction starts at a high price and steps down over time — the buyer who waits longest pays the least. The typical on-chain implementation derives price from block.timestamp:

// VULNERABLE: no monotonicity guard
function getCurrentPrice() public view returns (uint256) {
    uint256 elapsed = block.timestamp - startTime;
    if (elapsed >= duration) return endPrice;
    return startPrice - ((startPrice - endPrice) * elapsed / duration);
}

function mint() external payable {
    uint256 price = getCurrentPrice();
    require(msg.value >= price, "Insufficient payment");
    _mint(msg.sender, _nextTokenId());
}

The problem is that block.timestamp is set by the block proposer. Post-Merge Ethereum validators can shift the timestamp several seconds within protocol tolerance. A validator (or someone colluding with one) can manipulate block.timestamp forward to make getCurrentPrice() return a lower value than the market currently expects, buying tokens below the legitimate current price.

The deeper structural problem: there is no state checkpoint of what price was already charged. A related attack involves owner calls to setStartTime() mid-auction — resetting the clock and sending the price back to startPrice — then purchasing at the reset price before buyers notice.

Fix: Store the last-settled price and enforce monotonicity on every mint call:

uint256 public lastSettledPrice;

function mint() external payable {
    uint256 price = getCurrentPrice();
    require(price <= lastSettledPrice || lastSettledPrice == 0,
        "Price monotonicity violation");
    lastSettledPrice = price;
    require(msg.value >= price, "Insufficient payment");
    _mint(msg.sender, _nextTokenId());
}

2. Allowlist with No Per-Address Quantity Cap

Merkle allowlists prove that a given address is eligible to mint — but the proof says nothing about how many tokens that address is allowed to claim. Contracts that verify the Merkle proof but skip a per-address counter let a whitelisted address drain the entire allocation.

// VULNERABLE: proof verified, quantity not capped
function allowlistMint(uint256 quantity, bytes32[] calldata proof)
    external payable
{
    bytes32 leaf = keccak256(abi.encodePacked(msg.sender));
    require(MerkleProof.verify(proof, merkleRoot, leaf), "Invalid proof");
    require(msg.value == 0.08 ether * quantity, "Wrong payment");
    _mintBatch(msg.sender, quantity); // no per-address limit
}

A whitelisted address submits a valid proof once and passes quantity = 100. The contract confirms the address is on the list and mints 100 tokens. Every other allowlist participant gets nothing.

Fix: Add a per-address mint counter and cap it at the allocation per wallet.

// SECURE: quantity capped per address
uint256 public constant MAX_PER_WALLET = 2;
mapping(address => uint256) public _mintedByAddress;

function allowlistMint(uint256 quantity, bytes32[] calldata proof)
    external payable
{
    bytes32 leaf = keccak256(abi.encodePacked(msg.sender));
    require(MerkleProof.verify(proof, merkleRoot, leaf), "Invalid proof");
    require(_mintedByAddress[msg.sender] + quantity <= MAX_PER_WALLET,
        "Exceeds per-wallet limit");
    require(msg.value == 0.08 ether * quantity, "Wrong payment");
    _mintedByAddress[msg.sender] += quantity; // update before mint
    _mintBatch(msg.sender, quantity);
}

The _mintedByAddress counter is updated before _mintBatch to follow checks-effects-interactions. If the mint uses _safeMint, the callback fires before your state is finalized unless you update first.


3. Merkle Allowlist Replay Across Phases

Multi-phase launches — presale, then public, sometimes a third VIP phase — often reuse the same Merkle root across phases. The intent is "everyone from Phase 1 also gets access in Phase 2." The result is that Phase 1 participants can claim both their Phase 1 and Phase 2 allocations, often without anyone noticing until the collection sells out.

// VULNERABLE: single root and shared counter across all phases
bytes32 public merkleRoot; // same root for Phase 1 and Phase 2
mapping(address => uint256) public _mintedByAddress; // shared counter

function phaseMint(uint256 quantity, bytes32[] calldata proof) external payable {
    bytes32 leaf = keccak256(abi.encodePacked(msg.sender));
    require(MerkleProof.verify(proof, merkleRoot, leaf), "Invalid proof");
    require(_mintedByAddress[msg.sender] + quantity <= 2, "Limit exceeded");
    _mintedByAddress[msg.sender] += quantity;
    _mintBatch(msg.sender, quantity);
}

If Phase 1 presale and Phase 2 public sale use the same merkleRoot, a Phase 1 participant who claimed 2 tokens in Phase 1 can claim 0 more in Phase 2 — that part is correct. But if you issue separate per-phase counters without changing the root, the same proof works in both phases and each phase counter resets to 0, doubling the allocation.

Fix: Use distinct Merkle roots per phase and track mints per phase per address.

mapping(uint8 => bytes32) public phaseRoots;
mapping(uint8 => uint256) public phaseMaxPerWallet;
mapping(uint8 => mapping(address => uint256)) public phaseMinted;

function phaseMint(uint8 phase, uint256 quantity, bytes32[] calldata proof)
    external payable
{
    bytes32 root = phaseRoots[phase];
    require(root != bytes32(0), "Phase not configured");
    bytes32 leaf = keccak256(abi.encodePacked(msg.sender));
    require(MerkleProof.verify(proof, root, leaf), "Invalid proof");
    require(phaseMinted[phase][msg.sender] + quantity <= phaseMaxPerWallet[phase],
        "Phase limit exceeded");
    phaseMinted[phase][msg.sender] += quantity;
    _mintBatch(msg.sender, quantity);
}

Each phase has its own root, its own per-wallet cap, and its own counter namespace. A proof from Phase 1 will not verify against the Phase 2 root.


4. Predictable Reveal via Committed Metadata

Many projects mint tokens first and reveal metadata later — the token exists on-chain but tokenURI returns a placeholder until the reveal block. The vulnerability is that the metadata assignment is already determined before the reveal: it was committed at deploy time or at mint time using on-chain state.

// VULNERABLE: offset determined at reveal using public block state
uint256 private _offset;

function reveal() external onlyOwner {
    // blockhash is readable by anyone — attacker pre-computes _offset
    _offset = uint256(blockhash(block.number - 1)) % MAX_SUPPLY;
}

function tokenURI(uint256 tokenId) public view override returns (string memory) {
    uint256 metadataIndex = (tokenId + _offset) % MAX_SUPPLY;
    return string(abi.encodePacked(_baseURI(), metadataIndex.toString()));
}

blockhash(block.number - 1) is available to every contract and every read call at reveal time. A bot monitors the mempool for the reveal() transaction, simulates the _offset computation using the known block hash, maps every tokenId to its metadataIndex, and identifies which tokens correspond to legendary traits. The bot then front-runs buyers on the secondary market or submits buy orders before the reveal transaction is confirmed.

Fix: Use a commit-reveal scheme where the randomness seed is provided off-chain and committed before metadata is published, or use Chainlink VRF for verifiable on-chain randomness. The NFT Randomness Security post covers both patterns in depth.


5. Owner Can Mint Unlimited Tokens Before Public Sale

ownerMint() functions are a standard feature — teams need to reserve tokens for collaborators, advisors, giveaways. The vulnerability is when there is no cap on how many the owner can mint, and no on-chain visibility into how many were minted before the public launch.

// VULNERABLE: no cap on owner mints
function ownerMint(address to, uint256 quantity) external onlyOwner {
    require(totalMinted + quantity <= MAX_SUPPLY, "Exceeds max supply");
    // quantity can be anything up to MAX_SUPPLY — no team reserve limit
    for (uint256 i = 0; i < quantity; i++) _mint(to, totalMinted++);
}

Nothing prevents the owner from calling ownerMint() with quantity = 2000 before the public sale opens — claiming 20% of the collection. Those tokens can be listed on secondary markets immediately after reveal, dumping into the liquidity that public buyers created. Buyers have no on-chain way to know how many reserve mints occurred before they participated.

Fix: Hard-cap the team reserve with a constant and emit an event so the allocation is verifiable on-chain before the public sale.

uint256 public constant MAX_TEAM_MINT = 200; // 2% hard cap
uint256 public teamMinted;

event TeamMint(address indexed to, uint256 quantity, uint256 totalTeamMinted);

function ownerMint(address to, uint256 quantity) external onlyOwner {
    require(teamMinted + quantity <= MAX_TEAM_MINT, "Exceeds team reserve");
    require(totalMinted + quantity <= MAX_SUPPLY,   "Exceeds max supply");
    teamMinted  += quantity;
    totalMinted += quantity;
    for (uint256 i = totalMinted - quantity; i < totalMinted; i++) _mint(to, i);
    emit TeamMint(to, quantity, teamMinted);
}

Anyone can query teamMinted or filter TeamMint events to verify exactly how many reserve tokens exist and when they were issued — before spending a single wei in the public sale.


6. Dutch Auction Refund Logic with Reentrancy

Dutch auctions typically overcharge early buyers (who pay the starting price) and refund the difference once the auction settles at final price. The refund step is where reentrancy lands.

// VULNERABLE: external call before state update — reentrancy window
mapping(address => uint256) public _paid;

function claimRefund() external {
    uint256 paid   = _paid[msg.sender];
    uint256 refund = paid - settledPrice;
    require(refund > 0, "No refund due");
    (bool success, ) = msg.sender.call{value: refund}(""); // call first
    require(success, "Transfer failed");
    _paid[msg.sender] = settledPrice; // state update too late
}

An attacker contract calls claimRefund(). The ETH transfer triggers the attacker's receive() fallback, which calls claimRefund() again. _paid[msg.sender] still reflects the original amount because the state update hasn't run yet. The attacker drains the contract for multiples of their legitimate refund.

Fix: Apply checks-effects-interactions. Clear the state before the external call.

// SECURE: CEI order — state cleared before external call
function claimRefund() external {
    uint256 paid   = _paid[msg.sender];
    uint256 refund = paid - settledPrice;
    require(refund > 0, "No refund due");
    _paid[msg.sender] = settledPrice; // effect first
    (bool success, ) = msg.sender.call{value: refund}("");
    require(success, "Transfer failed");
}

Adding ReentrancyGuard from OpenZeppelin as a second line of defense is also worthwhile — it catches reentrancy attempts even when the CEI order is correct, and costs roughly 3,000 gas per call.


Secure Dutch Auction: Key Guard Patterns

The three fixes for vulnerabilities 1, 2, and 6 integrate cleanly into a single mint function. The critical patterns are:

// SECURE: monotonicity + per-wallet cap + CEI overpayment refund
contract SecureDutchAuction is ERC721, ReentrancyGuard, Ownable {
    uint256 public constant MAX_SUPPLY     = 5000;
    uint256 public constant MAX_PER_WALLET = 5;
    uint256 public lastSettledPrice;
    uint256 public totalMinted;
    mapping(address => uint256) public mintedByAddress;

    function mint(uint256 quantity) external payable nonReentrant {
        require(totalMinted + quantity <= MAX_SUPPLY, "Exceeds supply");
        require(mintedByAddress[msg.sender] + quantity <= MAX_PER_WALLET, "Per-wallet limit");

        uint256 price = getCurrentPrice();
        if (lastSettledPrice != 0) require(price <= lastSettledPrice, "Price moved up");
        lastSettledPrice = price;

        uint256 total = price * quantity;
        require(msg.value >= total, "Underpayment");

        // CEI: all state updates before external call
        mintedByAddress[msg.sender] += quantity;
        uint256 start = totalMinted;
        totalMinted  += quantity;
        for (uint256 i = start; i < start + quantity; i++) _mint(msg.sender, i);

        if (msg.value > total) {
            (bool ok, ) = msg.sender.call{value: msg.value - total}("");
            require(ok, "Refund failed");
        }
    }
}

Three guards work together: lastSettledPrice enforces monotonicity across the session; mintedByAddress caps per-wallet allocation before any mint; and CEI ordering settles all state before the overpayment refund fires. The nonReentrant modifier adds a second layer at ~3,000 gas per call.


What ContractScan Detects

Vulnerability Detection Method
Dutch auction price non-monotonicity Static analysis of price curve functions for missing storage checkpoint
Missing per-address mint cap Data flow analysis on allowlistMint and publicMint for _mintedByAddress guard
Merkle root reuse across phases Control flow analysis detecting same merkleRoot variable referenced in multiple phase conditions
Predictable reveal via blockhash Pattern matching on blockhash() and block.timestamp in tokenURI or reveal functions
Uncapped ownerMint Symbolic execution checking if owner mint quantity is bounded by a constant cap
Refund reentrancy (ETH call before state clear) Taint analysis tracing msg.sender.call before storage write to _paid or amountPaid

NFT mint contracts are live the moment startTime is set. There is no upgrade window during a 24-hour launch. Audit your mint mechanics before you go live — not after.

Scan your NFT contract before mint at https://contract-scanner.raccoonworld.xyz.


Scan your contract for this vulnerability
Free QuickScan — Unlimited quick scans. No signup required.. No signup required.
Scan a Contract →