
Building a Secure ERC721 Minting Contract with Per-Wallet Limits in Solidity
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/contractsSolidity 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 existmaxPerWallet: maximum number of NFTs a single wallet can minttotalMinted: running count of minted tokenssaleActive: public mint switchmintedByWallet: 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 zeromaxPerWallet_is greater than zeromaxPerWallet_does not exceedmaxSupply_
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:
- Sale must be active
- Quantity must be nonzero
- Total supply must not be exceeded
- 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.jsonThis 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
| Approach | Pros | Cons | Best for |
|---|---|---|---|
| Per-wallet limit in contract | Enforced on-chain, simple to audit | Does not stop Sybil wallets | Public sales with fairness rules |
| Frontend-only limit | Easy to implement | Easy to bypass | Prototypes only |
| Allowlist plus per-wallet limit | Stronger distribution control | More setup and storage | Pre-sales and curated drops |
| Signature-based mint authorization | Flexible and gas-efficient | More complex backend flow | Staged 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
| Scenario | Expected result |
|---|---|
| User mints 1 token during active sale | Success |
Same user mints beyond maxPerWallet | Revert with ExceedsWalletLimit |
| Minting would exceed total supply | Revert with ExceedsMaxSupply |
| Sale is inactive | Revert 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.
