8 Common NFT Smart Contract Mistakes Engineers Make (and How to Fix Them)
From reentrancy to metadata mutability, these are the NFT smart contract mistakes that end up costing projects money, trust, and sometimes the ability to operate.
Most NFT contract bugs do not look like bugs until they are being exploited or until a project can no longer operate the way it promised. The mistakes below appear repeatedly across audits, post-mortems, and community support channels. None of them are exotic. All of them are avoidable.
Mistake 1: No Reentrancy Protection on Mint Functions
If your mint function sends ETH (for refunds) or calls external contracts before updating state, it is vulnerable to reentrancy. An attacker can re-enter your mint function before the first call completes and mint more tokens than they paid for.
Fix: Follow the checks-effects-interactions pattern. Update all state (token counts, balances, mappings) before making any external call or transfer. For additional protection, use OpenZeppelin's ReentrancyGuard modifier on functions that involve ETH transfers.
Mistake 2: Unbounded Loops Over Token Supply
A function that iterates over all minted tokens — to compute rarity, check ownership, or calculate rewards — will eventually run out of gas as the collection grows. If the function is required for normal operation, your contract becomes unusable at scale.
Fix: Never iterate over token supply in a transaction. Use mappings and incremental state updates instead. If you need aggregate data, index it off-chain using an event listener.
Mistake 3: Mutable Metadata URI With No Lock
Setting a baseURI is common. What many projects forget is that if the owner can call setBaseURI at any time forever, collectors have no guarantee that the metadata they bought will remain the metadata they hold. This is a rug vector — even if unintentional.
Fix: Build a lockMetadata() function that permanently disables setBaseURI. Call it after reveal. Emit an event so the state change is publicly verifiable. Collectors should be able to confirm on-chain that metadata is frozen.
Mistake 4: Weak Randomness for Reveal or Trait Assignment
Using block.timestamp, block.difficulty, or blockhash as a randomness source is not secure. Validators can influence these values. Miners used to front-run this for profitable trait assignments, and the same risk exists today.
Fix: Use Chainlink VRF (Verifiable Random Function) for any randomness that affects token value. It is an external call and adds complexity, but it is the only credible source of verifiable randomness for trait assignment and reveal mechanics.
Mistake 5: Missing Access Control on Sensitive Functions
A withdraw() function anyone can call. A mint() with no onlyOwner guard. A setRoyalty() that accepts any caller. These are not hypotheticals — they appear in audited codebases.
Fix: Use OpenZeppelin's Ownable or AccessControl on every function that changes state, withdraws funds, or alters configuration. Review each public function and ask: who should be allowed to call this?
Mistake 6: Integer Overflow on Supply Caps
Solidity 0.8+ has built-in overflow protection, but projects using older compilers or upgrading legacy code may still have unchecked arithmetic. If your mint count can overflow, an attacker can wrap it around and mint beyond your supply cap.
Fix: Use Solidity 0.8.x or later. If you use unchecked blocks for gas optimization, be explicit about which arithmetic is safe to leave unchecked and document why.
Mistake 7: Allowing ETH to Get Stuck
A contract that receives ETH but has no withdraw function — or has a broken one — will lock funds permanently. Contracts also get ETH sent to them accidentally. If there is no recovery path, that ETH is gone.
Fix: Include a withdraw() function with appropriate access control. Test it. If your contract accepts ETH, test that withdraw correctly drains the full balance, including edge cases around partial mints and refunds.
Mistake 8: Signature Replay in Allowlist Minting
Allowlist mints typically use signed messages to authorize specific addresses. If the signature is not tied to the specific contract address and chain ID, the same signature can be replayed on a different deployment — or on a different chain where you have deployed a copy.
Fix: Include the contract address and chain ID in the signed message. Use EIP-712 typed data signing, which handles this structurally. OpenZeppelin's EIP712 base contract makes this straightforward and produces human-readable wallet prompts.
The Pattern Behind All of These Mistakes
Most of these bugs share a common root: the contract was written to make the happy path work and the error paths were not examined. Security in Solidity is adversarial. Assume every public function will be called with adversarial inputs, maximum values, and reentrancy attempts.
A few practices that catch most of this before deployment:
- Write tests for every function, including failure cases and edge cases.
- Run your contract through Slither or another static analysis tool before audit.
- Get an independent audit from a firm that specializes in Solidity — not a generalist developer doing a "review."
- Deploy to a testnet and run through the entire user flow before mainnet.
Smart contract bugs on mainnet are not patches — they are post-mortems. Ship it right the first time.
If you want your NFT contract reviewed or built with these patterns embedded from the start, talk to the Clixo team. We build production-grade NFT contracts and can audit contracts you have already written.