
Building a Secure ERC721 Enumerable Mint Tracker in Solidity
Why a mint tracker matters
ERC721 itself does not require a contract to expose a full list of minted token IDs. Many applications only need ownership checks and tokenURI, but real projects often need more:
- a public way to verify total minted supply
- a list of all minted token IDs for dashboards or metadata services
- predictable minting for drops, claims, or game items
- admin review of which IDs are already in circulation
A mint tracker is useful when you want on-chain truth for mint history without adopting a full enumerable extension or depending entirely on indexing infrastructure.
When to use this pattern
Use a mint tracker when:
- token IDs are sequential or otherwise easy to enumerate
- you want to prevent double-minting of the same ID
- you need a compact on-chain source of minted IDs
- you want to keep the contract simpler than a full ERC721 enumerable implementation
Avoid it when:
- your collection is very large and storing every minted ID is too expensive
- token IDs are highly sparse and not meaningful to enumerate
- you already rely on a mature indexing pipeline and do not need on-chain enumeration
Design goals
A secure mint tracker should satisfy a few core properties:
- No duplicate mints: each token ID can be minted once.
- Accurate supply accounting: total minted count must always match recorded state.
- Efficient reads: retrieving minted IDs should be straightforward for off-chain consumers.
- Clear access control: only authorized accounts can mint if required.
- Minimal state corruption risk: state updates should happen before external interactions.
The implementation below uses a mapping to mark minted IDs and an array to preserve mint order.
Contract overview
The contract will:
- inherit from OpenZeppelin
ERC721andOwnable - allow the owner to mint specific token IDs
- reject duplicate token IDs
- store minted IDs in an array
- expose helper functions for total minted supply and retrieval by index
Storage layout
We use two main state variables:
mapping(uint256 => bool) private _minted;
Tracks whether a token ID has already been minted.
uint256[] private _mintedIds;
Stores the list of minted token IDs in mint order.
This combination gives us constant-time duplicate checks and simple enumeration.
Full example
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract EnumerableMintTracker is ERC721, Ownable {
mapping(uint256 => bool) private _minted;
uint256[] private _mintedIds;
event TokenMinted(uint256 indexed tokenId, address indexed to);
constructor(
string memory name_,
string memory symbol_
) ERC721(name_, symbol_) Ownable(msg.sender) {}
function mint(address to, uint256 tokenId) external onlyOwner {
require(to != address(0), "Invalid recipient");
require(!_minted[tokenId], "Token already minted");
// Effects first: update state before external interaction.
_minted[tokenId] = true;
_mintedIds.push(tokenId);
_safeMint(to, tokenId);
emit TokenMinted(tokenId, to);
}
function totalMinted() external view returns (uint256) {
return _mintedIds.length;
}
function mintedAt(uint256 index) external view returns (uint256) {
require(index < _mintedIds.length, "Index out of bounds");
return _mintedIds[index];
}
function isMinted(uint256 tokenId) external view returns (bool) {
return _minted[tokenId];
}
function mintedIds(uint256 start, uint256 count)
external
view
returns (uint256[] memory ids)
{
require(start < _mintedIds.length || _mintedIds.length == 0, "Start out of bounds");
uint256 end = start + count;
if (end > _mintedIds.length) {
end = _mintedIds.length;
}
ids = new uint256[](end - start);
for (uint256 i = start; i < end; i++) {
ids[i - start] = _mintedIds[i];
}
}
}How the contract works
1. Duplicate prevention
The require(!_minted[tokenId], "Token already minted"); check ensures the same token ID cannot be minted twice. This is the most important invariant in the contract.
Because the mapping is updated before _safeMint, even if the recipient is a contract and the mint triggers a callback, the token is already marked as minted.
2. Safe minting
_safeMint(to, tokenId) is preferred over _mint because it checks whether the recipient contract can handle ERC721 tokens. This reduces the risk of locking tokens in contracts that do not implement onERC721Received.
3. Enumeration
The _mintedIds array preserves mint order. Off-chain tools can read:
totalMinted()to get the current countmintedAt(index)to fetch a specific token IDmintedIds(start, count)to page through the list
Pagination matters because returning a very large array in one call can become expensive or fail for large collections.
Best practices for secure mint tracking
Update state before external calls
The contract follows the checks-effects-interactions pattern:
- check conditions
- update internal state
- call external code
This reduces the chance of reentrancy-related inconsistencies. Even though _safeMint is part of ERC721, it can still invoke external code if the recipient is a contract.
Keep enumeration read-only
Never expose functions that allow arbitrary removal or mutation of _mintedIds unless you have a strong reason and a carefully designed invariant. Enumeration should reflect historical mint state, not a mutable admin-controlled list.
Prefer pagination for large collections
Returning all minted IDs in one call is convenient for small projects, but it does not scale well. A paginated interface is easier for frontends, subgraphs, and scripts to consume.
Emit events for off-chain indexing
The TokenMinted event provides a reliable log for analytics and monitoring. Even if you store minted IDs on-chain, events remain useful for fast indexing and historical queries.
Consider supply caps separately
If your collection has a maximum size, enforce it explicitly:
require(_mintedIds.length < maxSupply, "Max supply reached");Do not assume that duplicate prevention alone is enough to enforce a collection limit.
Adding a max supply
A common extension is to cap the number of minted tokens. This is especially useful for fixed-size NFT collections.
uint256 public immutable maxSupply;
constructor(
string memory name_,
string memory symbol_,
uint256 maxSupply_
) ERC721(name_, symbol_) Ownable(msg.sender) {
require(maxSupply_ > 0, "Invalid max supply");
maxSupply = maxSupply_;
}
function mint(address to, uint256 tokenId) external onlyOwner {
require(to != address(0), "Invalid recipient");
require(!_minted[tokenId], "Token already minted");
require(_mintedIds.length < maxSupply, "Max supply reached");
_minted[tokenId] = true;
_mintedIds.push(tokenId);
_safeMint(to, tokenId);
emit TokenMinted(tokenId, to);
}This version keeps the same mint-tracking behavior while adding a hard upper bound.
Comparison with other approaches
| Approach | Pros | Cons |
|---|---|---|
| Mapping only | Cheap duplicate checks, simple state | No built-in enumeration |
| Array only | Easy to list minted IDs | Duplicate checks are expensive and error-prone |
| Mapping + array | Fast checks and easy enumeration | Slightly higher storage cost |
| Full ERC721Enumerable | Standardized enumeration API | More overhead and often unnecessary |
For many projects, mapping + array is the best balance between simplicity and functionality.
Common mistakes to avoid
1. Using tx.origin for authorization
Do not use tx.origin to restrict minting. Use onlyOwner, roles, or explicit allowlists instead. tx.origin is brittle and can be abused through intermediary contracts.
2. Forgetting to validate recipient addresses
Always reject the zero address. Minting to address(0) is almost always a bug.
3. Exposing unbounded arrays
A function that returns the entire _mintedIds array may work in testing but become impractical in production. Use pagination.
4. Updating the array after _safeMint
If you push to _mintedIds after the external call, a malicious recipient contract could reenter and observe inconsistent state. Update storage first.
5. Assuming token IDs are sequential
This pattern works with arbitrary token IDs too, but your off-chain tooling may incorrectly assume sequential IDs. If IDs are sparse, document that clearly.
Testing scenarios to cover
A robust test suite should verify the following:
- minting a fresh token succeeds
- minting the same token twice reverts
totalMinted()increments correctlymintedAt(index)returns the expected token IDmintedIds(start, count)returns the correct slice- minting to a contract that implements
onERC721Receivedsucceeds - minting to a contract that does not implement the receiver interface reverts
If you add a max supply, also test that the contract rejects mints after the cap is reached.
Practical integration tips
Frontend usage
A frontend can combine event logs and on-chain reads:
- use
totalMinted()to display current supply - page through
mintedIds(start, count)for collection views - listen to
TokenMintedfor live updates
Indexer usage
An indexer can treat the TokenMinted event as the primary source of truth and use on-chain reads as a consistency check. This is especially helpful when rebuilding state after a deployment or chain reorganization.
Metadata systems
If token metadata depends on token ID order, the mint tracker gives you a deterministic list of issued IDs. That can simplify reveal logic, rarity assignment, or post-mint analytics.
Extending the pattern safely
You can adapt this contract in several directions:
- Role-based minting: replace
onlyOwnerwithAccessControlif multiple minters are needed. - Batch minting: add a loop that mints multiple token IDs in one transaction, but keep duplicate checks per ID.
- Burn tracking: if tokens can be burned, decide whether burned IDs should remain in the minted list. In most cases, historical mint data should remain immutable.
- Per-wallet mint stats: add a mapping from address to mint count if you need user-level analytics.
When extending the contract, preserve the core invariant: a token ID can be marked minted exactly once.
Final thoughts
A secure ERC721 mint tracker is a practical pattern for NFT projects that need transparent supply accounting and simple enumeration without the complexity of a full enumerable extension. By combining a duplicate-check mapping with an ordered array, you get efficient mint validation and straightforward read access for dashboards, scripts, and metadata services.
The key is to keep the design small, enforce invariants early, and treat enumeration as a read-only record of mint history. That approach scales well for many real-world collections and keeps your contract easier to audit.
