Why per-wallet mint limits matter

Per-wallet limits are a simple but effective defense against a few common problems:

  • Bot domination: one address cannot mint the entire supply in a single transaction.
  • Fair distribution: more users get access during a public mint.
  • Operational safety: you reduce the risk of accidental oversubscription.
  • Clear sale rules: the contract itself enforces the policy, not the frontend.

A mint limit is not a complete anti-bot solution, but it is a strong baseline. It works best when combined with a max supply, a sale toggle, and optional allowlist logic.


Contract design goals

We will build an ERC721 contract with these properties:

  • Fixed maximum supply
  • Fixed per-wallet mint cap
  • Owner-controlled sale activation
  • Safe minting with clear revert reasons
  • Simple metadata URI handling

The contract will use OpenZeppelin’s battle-tested building blocks for ERC721, ownership, and counters.


Project setup

This example assumes a standard Solidity development environment such as Hardhat or Foundry. You will need OpenZeppelin contracts installed.

Dependencies

npm install @openzeppelin/contracts

Solidity version

Use a recent compiler version such as ^0.8.20 to benefit from built-in overflow checks and modern language features.


Full contract example

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/access/Ownable.sol";

contract LimitedMintNFT is ERC721, Ownable {
    uint256 public immutable maxSupply;
    uint256 public immutable maxPerWallet;
    uint256 public totalMinted;
    bool public saleActive;

    string private baseTokenURI;

    mapping(address => uint256) public mintedByWallet;

    error SaleNotActive();
    error ExceedsMaxSupply();
    error ExceedsWalletLimit();
    error InvalidMintAmount();

    constructor(
        string memory name_,
        string memory symbol_,
        string memory baseTokenURI_,
        uint256 maxSupply_,
        uint256 maxPerWallet_
    ) ERC721(name_, symbol_) Ownable(msg.sender) {
        require(maxSupply_ > 0, "maxSupply must be > 0");
        require(maxPerWallet_ > 0, "maxPerWallet must be > 0");
        require(maxPerWallet_ <= maxSupply_, "wallet limit too high");

        maxSupply = maxSupply_;
        maxPerWallet = maxPerWallet_;
        baseTokenURI = baseTokenURI_;
    }

    function setSaleActive(bool active) external onlyOwner {
        saleActive = active;
    }

    function setBaseTokenURI(string calldata newBaseURI) external onlyOwner {
        baseTokenURI = newBaseURI;
    }

    function mint(uint256 quantity) external {
        if (!saleActive) revert SaleNotActive();
        if (quantity == 0) revert InvalidMintAmount();

        uint256 newTotalMinted = totalMinted + quantity;
        if (newTotalMinted > maxSupply) revert ExceedsMaxSupply();

        uint256 newWalletMinted = mintedByWallet[msg.sender] + quantity;
        if (newWalletMinted > maxPerWallet) revert ExceedsWalletLimit();

        mintedByWallet[msg.sender] = newWalletMinted;
        totalMinted = newTotalMinted;

        for (uint256 i = 0; i < quantity; i++) {
            uint256 tokenId = totalMinted - quantity + i + 1;
            _safeMint(msg.sender, tokenId);
        }
    }

    function tokenURI(uint256 tokenId) public view override returns (string memory) {
        _requireOwned(tokenId);
        return string.concat(baseTokenURI, _toString(tokenId), ".json");
    }

    function _toString(uint256 value) internal pure returns (string memory) {
        if (value == 0) return "0";

        uint256 temp = value;
        uint256 digits;
        while (temp != 0) {
            digits++;
            temp /= 10;
        }

        bytes memory buffer = new bytes(digits);
        while (value != 0) {
            digits -= 1;
            buffer[digits] = bytes1(uint8(48 + value % 10));
            value /= 10;
        }

        return string(buffer);
    }
}

How the contract works

State variables

The contract stores the main sale constraints:

  • maxSupply: total number of NFTs that can ever exist
  • maxPerWallet: maximum number of NFTs a single wallet can mint
  • totalMinted: running count of minted tokens
  • saleActive: public mint switch
  • mintedByWallet: per-address mint tracking

Using immutable for supply limits is a good practice because it makes the rules obvious and prevents accidental changes after deployment.

Constructor validation

The constructor checks that:

  • maxSupply_ is greater than zero
  • maxPerWallet_ is greater than zero
  • maxPerWallet_ does not exceed maxSupply_

This prevents nonsensical configurations such as a wallet cap larger than the total supply.

Mint flow

The mint() function performs checks in a predictable order:

  1. Sale must be active
  2. Quantity must be nonzero
  3. Total supply must not be exceeded
  4. Wallet limit must not be exceeded

Only after all checks pass does the contract update accounting and mint tokens.

This order matters because it gives users precise revert reasons and avoids partially updated state.


Why the mint loop is safe here

The contract mints multiple NFTs in a loop using _safeMint(). This is acceptable for small mint quantities, especially when the per-wallet cap is low.

However, loops in Solidity should always be used carefully. If you allow large mint quantities, gas costs can become high and transactions may fail. For that reason, a per-wallet cap is also a practical gas-control mechanism.

If you expect large batch mints, consider a separate design that uses ERC721A-style ownership packing or a different token standard.


Metadata strategy

The tokenURI() function returns a URI in the form:

baseURI + tokenId + ".json"

For example, if baseTokenURI is:

https://example.com/metadata/

then token 7 resolves to:

https://example.com/metadata/7.json

This is a common pattern for NFT collections with off-chain metadata stored on a web server, IPFS gateway, or decentralized storage service.

Best practices for metadata

  • Keep the base URI stable after launch if possible
  • Ensure metadata files exist before revealing tokens
  • Use consistent naming conventions for token files
  • Validate that your metadata server returns correct content types

If you want a delayed reveal, you can initially point baseTokenURI to placeholder metadata and update it later.


Comparing mint-limit approaches

ApproachProsConsBest for
Per-wallet limit in contractEnforced on-chain, simple to auditDoes not stop Sybil walletsPublic sales with fairness rules
Frontend-only limitEasy to implementEasy to bypassPrototypes only
Allowlist plus per-wallet limitStronger distribution controlMore setup and storagePre-sales and curated drops
Signature-based mint authorizationFlexible and gas-efficientMore complex backend flowStaged or invite-only mints

A contract-level limit is the minimum you should use for any serious mint.


Common pitfalls to avoid

1. Forgetting to track wallet mints

If you only check quantity <= maxPerWallet, a user can call mint() repeatedly and bypass the cap. Always store cumulative mints per address.

2. Updating state after minting

State should be updated before or immediately around minting logic. If you mint first and update later, you risk inconsistent accounting if a later operation fails.

3. Allowing zero-quantity mints

Zero-value mints waste gas and complicate off-chain analytics. Reject them explicitly.

4. Using mutable supply caps without governance

Changing maxSupply after deployment can undermine trust. If you need flexibility, make it a deliberate governance decision and document it clearly.

5. Ignoring contract recipients

_safeMint() is preferred over _mint() because it checks whether the recipient can handle ERC721 tokens. This reduces the chance of tokens being locked in contracts that do not implement onERC721Received.


Extending the design

This contract is a solid base, but real projects often need more features.

Add a paid mint

You can require msg.value to equal quantity * pricePerToken and forward funds to a treasury address. If you do this, keep the payment logic separate from the supply checks for readability.

Add an allowlist

Use a Merkle tree or signed authorization to restrict minting to approved wallets during an early phase. The per-wallet cap still applies, but only eligible addresses can mint.

Add phased sales

You can introduce multiple sale stages, each with its own price and wallet limit. For example:

  • Presale: 2 tokens per wallet
  • Public sale: 5 tokens per wallet

A simple enum or phase ID can help keep this manageable.

Add admin minting

Many collections reserve a small number of tokens for the team. If you add admin minting, ensure it respects the max supply and is clearly separated from public minting.


Testing recommendations

A mint-limited NFT contract should be tested with both happy-path and failure-path cases.

Essential tests

  • Minting fails when sale is inactive
  • Minting fails with quantity 0
  • Minting fails when exceeding max supply
  • Minting fails when exceeding wallet limit
  • Minting succeeds for valid quantities
  • Token URI resolves correctly after mint
  • Owner can toggle sale state
  • Owner can update base URI

Example test scenarios

ScenarioExpected result
User mints 1 token during active saleSuccess
Same user mints beyond maxPerWalletRevert with ExceedsWalletLimit
Minting would exceed total supplyRevert with ExceedsMaxSupply
Sale is inactiveRevert with SaleNotActive

Good tests are especially important when mint logic includes loops and multiple checks.


Deployment and operational advice

Before deploying to mainnet or a production L2, verify the following:

  • The constructor parameters are correct
  • The base URI points to the intended metadata source
  • The owner account is secured, ideally with a multisig
  • The sale activation process is documented
  • The mint limit matches the intended launch policy

If you plan to reveal metadata later, test the full lifecycle on a testnet first. A bad URI update can make the collection appear broken even if the contract is correct.


When this pattern is the right choice

Use this pattern when you need:

  • A straightforward NFT public sale
  • On-chain enforcement of fair minting rules
  • A small to medium collection with predictable mint sizes
  • A codebase that is easy to audit and maintain

If your project needs highly optimized batch minting, advanced royalty logic, or complex sale phases, this contract should be treated as a foundation rather than a final product.


Learn more with useful resources