8 Common NFT Smart Contract Mistakes Engineers Make (and How to Fix Them)
Eight NFT contract checks covering minting, reentrancy, metadata changes, randomness, access controls, withdrawals, and signed requests.
Review minting, metadata changes, withdrawals, and signature validation before deploying an NFT contract. The checks below focus on state changes and failure paths that can affect token supply or funds.
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. Account for the external dependency and request lifecycle in the reveal design.
Mistake 5: Missing Access Control on Sensitive Functions
Review privileged withdrawals and configuration changes for missing access checks. Public minting may intentionally be open, but still needs its intended eligibility, payment, and supply rules.
Fix: Specify who may call each function. Apply Ownable or role checks to privileged operations and enforce the intended rules on public operations. OpenZeppelin access control.
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.
Test failure paths before deployment and document how the team would respond to a defect after launch.