Appearance
L1-12: Gated NFT(门控 NFT)
1. 问题
创建一个 ERC-721 NFT 合约,要求用户在铸造时必须支付指定的 ETH 费用。核心需求:可配置的铸造价格、最大供应量限制、铸造数量追踪、超额支付找零。
这本质上是「付费门槛」模式——用户需要通过经济投入来获得 NFT,同时合约需要确保不会超发,并正确处理用户多付的 ETH。
2. 原因
NFT 铸造的经济模型是每个 NFT 项目的基础。几乎所有 NFT 项目都需要:
- 付费铸造:防止机器人批量免费领取(Sybil 攻击)
- 供应量上限:创造稀缺性,维护价值
- 价格可调:适应市场变化(预售 vs 公开铸造不同价格)
门控 NFT 是理解「payable + access control + supply cap」组合的第一课。相比白名单(Merkle Proof),付费门槛更简单直接,是进入 NFT 世界的起点。
3. 方案
合约架构
继承 OpenZeppelin ERC-721,添加支付验证逻辑:
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++;
// 找零:如果用户多付了,退差额
if (msg.value > mintPrice) {
payable(msg.sender).transfer(msg.value - mintPrice);
}
}
}
关键机制
- 铸造门槛:
msg.value >= mintPrice确保经济门槛 - 供应上限:
totalSupply < maxSupply防止超发 - 自增 ID:
_tokenIdCounter++为每个 NFT 分配唯一 ID - 找零处理:超额支付时退还差额,避免用户资金损失
4. 遭遇的陷阱
4.1 找零中的重入风险
在 mint() 中先 _safeMint() 再 transfer() 找零——如果 _safeMint() 触发了接收方的 onERC721Received 回调,此时 totalSupply 已增加但找零尚未完成。一个恶意的接收方合约可能在回调中重新调用 mint()。
4.2 totalSupply 追踪不准确
OpenZeppelin ERC-721 内部没有追踪 totalSupply。如果手动维护的 totalSupply 计数器与实际的 balanceOf 分布不一致(例如通过 _burn 销毁),会导致供应上限逻辑出错。
4.3 price 更新的时间窗口
如果在 mint() 执行期间 mintPrice 被 owner 通过另一个交易修改,用户可能在不知情的情况下以更高的价格铸造。在高 Gas 环境下,交易可能在 mempool 中等待数分钟,价格可能已经变化。
5. 陷阱的原因
5.1
_safeMint() 内部会检查接收地址是否为合约。如果是合约,它会调用 onERC721Received(to, operator, from, tokenId, data)。这个回调给了攻击者一个执行窗口,虽然在此场景中攻击面有限(因为 mintPrice 已付、totalSupply 已增),但违反了 CEI 原则。
5.2
totalSupply 不是 ERC-721 标准的一部分,每个合约自行维护。如果使用 _burn() 销毁 NFT 而不减少 totalSupply,会导致实际可铸造数量少于预期。
5.3
Solidity 的交易是原子的——单个交易中的价格不会变。但如果依赖前端显示的价格,用户可能在点击「铸造」时看到的是旧价格。正确做法是合约使用交易当时的 mintPrice,前端需要监听 MintPriceChanged 事件。
6. 如何解决陷阱
- 遵循 CEI 模式:先验证(Checks),再更新状态(Effects),最后做找零转账(Interactions)
- 如果项目支持 NFT 销毁,在
_burn()中也减少totalSupply - 使用
nonReentrantmodifier 保护mint()函数 - 找零放在所有状态更新之后,或使用
pull-over-push模式让用户自行提取超额付款
7. 技术要点
| 要点 | 说明 |
|---|---|
| payable mint | msg.value 验证付费门槛 |
| 供应上限 | totalSupply < maxSupply 防超发 |
| 自增 tokenId | _tokenIdCounter++ 顺序分配 |
| 找零处理 | 超额支付退还差额 |
| ERC-721 安全铸造 | _safeMint 检查接收方是否为合约 |
| 访问控制 | Owner 可修改 mintPrice 适应市场 |