Appearance
L1-12: Gated NFT
1. Problem
Create an ERC-721 NFT contract that requires users to pay a specified ETH fee when minting. Core requirements: configurable mint price, maximum supply limit, mint count tracking, and change refund for overpayment.
This is essentially the "paywall" pattern — users must commit economic value to obtain the NFT, while the contract must ensure no over-minting and correctly handle excess ETH paid by users.
2. Why
The economic model of NFT minting is the foundation of every NFT project. Almost all NFT projects require:
- Paid minting: Prevent bots from claiming free mints in bulk (Sybil attacks)
- Supply cap: Create scarcity and preserve value
- Adjustable price: Adapt to market changes (different prices for presale vs. public mint)
A gated NFT is the first lesson in understanding the "payable + access control + supply cap" combination. Compared to whitelists (Merkle Proof), a paywall is simpler and more direct — it is the entry point into the NFT world.
3. Solution
Contract Architecture
Inherit from OpenZeppelin ERC-721 and add payment validation logic:
solidity
contract GatedNFT is ERC721 {
uint256 public mintPrice;
uint256 public maxSupply;
uint256 public totalSupply;
uint256 private _tokenIdCounter;
constructor(
uint256 _mintPrice,
uint256 _maxSupply
) ERC721("GatedNFT", "GTD") {
mintPrice = _mintPrice;
maxSupply = _maxSupply;
}
function mint() external payable {
require(msg.value >= mintPrice, "Insufficient payment");
require(totalSupply < maxSupply, "Max supply reached");
uint256 tokenId = _tokenIdCounter;
_tokenIdCounter++;
_safeMint(msg.sender, tokenId);
totalSupply++;
// Change refund: if user overpaid, return the difference
if (msg.value > mintPrice) {
payable(msg.sender).transfer(msg.value - mintPrice);
}
}
}
Key Mechanisms
- Mint threshold:
msg.value >= mintPriceenforces the economic barrier - Supply cap:
totalSupply < maxSupplyprevents over-minting - Auto-increment ID:
_tokenIdCounter++assigns a unique ID to each NFT - Change refund: Return the difference on overpayment to avoid user fund loss
4. Pitfalls Encountered
4.1 Reentrancy Risk in Change Refund
In mint(), _safeMint() is called before transfer() for change — if _safeMint() triggers the recipient's onERC721Received callback, at that point totalSupply has already increased but the change refund has not yet completed. A malicious recipient contract could re-call mint() during the callback.
4.2 Inaccurate totalSupply Tracking
OpenZeppelin ERC-721 does not track totalSupply internally. If the manually maintained totalSupply counter becomes inconsistent with the actual balanceOf distribution (for example, through _burn), the supply cap logic will break.
4.3 Time Window for Price Updates
If mintPrice is modified by the owner through another transaction while mint() is executing, users may unknowingly mint at a higher price. In high-gas environments, transactions can sit in the mempool for minutes, during which the price may have changed.
5. Why the Pitfalls Exist
5.1
_safeMint() internally checks whether the recipient address is a contract. If it is a contract, it calls onERC721Received(to, operator, from, tokenId, data). This callback gives the attacker an execution window. Although the attack surface is limited in this scenario (because mintPrice has already been paid and totalSupply has already increased), it still violates the CEI principle.
5.2
totalSupply is not part of the ERC-721 standard; each contract maintains it independently. If NFTs are burned using _burn() without decrementing totalSupply, the actual mintable quantity will be less than expected.
5.3
Solidity transactions are atomic — the price does not change within a single transaction. However, if relying on the price displayed by the frontend, users may see an old price when clicking "Mint." The correct approach is for the contract to use the mintPrice at transaction time, and for the frontend to listen for MintPriceChanged events.
6. How to Solve the Pitfalls
- Follow the CEI pattern: validate first (Checks), update state second (Effects), and process change refund last (Interactions)
- If the project supports NFT burning, also decrement
totalSupplyin_burn() - Use the
nonReentrantmodifier to protect themint()function - Place change refund after all state updates, or use the "pull-over-push" pattern to let users withdraw excess payments themselves
7. Technical Takeaways
| Point | Description |
|---|---|
| payable mint | msg.value validates the payment barrier |
| Supply cap | totalSupply < maxSupply prevents over-minting |
| Auto-increment tokenId | _tokenIdCounter++ assigns IDs sequentially |
| Change refund | Return the difference on overpayment |
| ERC-721 safe minting | _safeMint checks whether the recipient is a contract |
| Access control | Owner can modify mintPrice to adapt to the market |