Appearance
L2-11: NFT Price Curve(Bonding Curve 动态定价)
1. 问题
固定价格的 NFT 铸造存在严重的不公平问题:机器人(MEV bots)和 Gas 竞价者总能在开售瞬间抢走低价 NFT,普通用户只能望而却步——或者从二级市场高价购买。这种"先到先得"的固定定价模型将 NFT 分发变成了纯 Gas 战争。
Bonding Curve(联合曲线)通过 price = f(mintedCount) 的函数定价替换固定价格:第 1 个铸造者支付最低价,第 50 个支付远高于第 1 个的价格。早鸟因为承担了"项目可能失败"的高风险而获得低价奖励;后来者因为看到 supply 已经很多、项目已经有了一定的社区验证而支付溢价。这种自我调节的定价机制创造了更公平的分发。
本挑战(参考 src/level2/NFTCurvePrice.sol)要求实现一个基于 ERC-721 的 NFT 合约,其铸造价格随已铸造数量线性增长:price = BASE_PRICE + totalSupply * PRICE_INCREMENT。
2. 原因
Bonding Curve 并非 NFT 独有——它是 DeFi 中"连续做市"的核心原语。Uniswap 的 x * y = k 本质上是一条 bonding curve(价格随储备变化);Bancor 协议通过 bonding curve 创建了完全抵押的弹性供应代币;Friend.tech 的 Key 定价同样基于 bonding curve。
在学习层面,NFTCurvePrice 是理解"动态定价"的入口:如何从状态变量(totalSupply)推导出价格?如何在理论上无限的 price 值域中防御整数溢出?如何处理超付(overpayment)退款?这些问题的答案构成了 DeFi 定价协议的基础思维模型。
线性曲线是最简单的 Bonding Curve,但它并不是唯一的。指数曲线(price = BASE * 2^(n/k))制造强烈的 FOMO 效应——早期极便宜,后期极贵;二次曲线(price = n^2 / k)奖励最早期的支持者;Sigmoid 曲线呈 S 形——中间段价格梯度最陡,在 50% 供应量附近制造最大紧迫感。理解这些曲线的差异,是在设计中做出正确经济决策的前提。
3. 方案
NFTCurvePrice 合约(参考 src/level2/NFTCurvePrice.sol)继承 ERC721 和 ReentrancyGuard,实现线性定价 + 自动退款:
solidity
contract NFTCurvePrice is ERC721, ReentrancyGuard {
uint256 public constant BASE_PRICE = 0.01 ether; // 第一个的铸造价格
uint256 public constant PRICE_INCREMENT = 0.001 ether; // 每多铸造一个,涨价 0.001 ETH
uint256 public constant MAX_SUPPLY = 50;
uint256 private _tokenIdCounter;
function getMintPrice() public view returns (uint256) {
return BASE_PRICE + (_tokenIdCounter * PRICE_INCREMENT);
// 第 0 个(未铸造任何): 0.01 ETH
// 第 1 个: 0.011 ETH
// 第 49 个: 0.059 ETH
}
function mint() external payable nonReentrant {
if (_tokenIdCounter >= MAX_SUPPLY) revert MaxSupplyReached();
uint256 price = getMintPrice();
if (msg.value < price) revert InsufficientPayment(msg.value, price);
uint256 tokenId = _tokenIdCounter;
_tokenIdCounter++;
_safeMint(msg.sender, tokenId);
emit Minted(msg.sender, tokenId, price);
// 自动退款:如果用户多付了,退还差额
uint256 overpayment = msg.value - price;
if (overpayment > 0) {
(bool success,) = msg.sender.call{value: overpayment}("");
if (!success) revert RefundFailed();
emit Refunded(msg.sender, overpayment);
}
}
}
定价曲线对比
| 曲线类型 | 公式 | 第 1 个价格 | 第 25 个价格 | 第 50 个价格 | 适用场景 |
|---|---|---|---|---|---|
| 线性 | BASE + n * STEP | 0.01 | 0.035 | 0.059 | 简单公平 |
| 二次 | n^2 / k | ~0.0001 | ~0.0625 | ~0.25 | 极早期奖励 |
| Sigmoid | 分段函数 | 0.01 | 0.035 | 0.29 | 中段紧迫感 |
超额退款(Overpayment Refund)模式
退款是 Bonding Curve 铸造的用户体验关键。由于价格随 supply 实时变化,用户在前端看到的报价与交易实际被打包时的链上价格可能不同(因为中间可能有其他人铸造了)。退款机制允许用户发送一个"上限"金额,实际按链上当前价格结算,多余的自动退回。这是 msg.value >= price 而非 msg.value == price 的设计原因。
4. 遭遇的陷阱
- 定价时机错误:在
_tokenIdCounter++之后才调用getMintPrice(),导致用户支付的是下一个人的价格 - 退款的重入风险:在
_safeMint(外部调用)之后执行退款(又一个外部调用),总共有两条外部调用路径——重入面扩大 - 退款失败导致整个交易回滚:如果退款接收方是一个恶意合约(拒绝接收 ETH),
RefundFailed错误会回滚整个交易——用户既得不到 NFT 也拿不回退款 - 整数溢出:虽然 Solidity 0.8+ 默认检查溢出,但
totalSupply * PRICE_INCREMENT在 supply 极大时仍可能溢出——需确保上限不会产生溢出價格 - Pull over Push 模式缺失:直接通过
call退款(push 模式)而非让用户自己来 claim(pull 模式)——push 模式在用户是合约时可能失败
5. 陷阱的原因
定价时机是状态更新的经典时序问题。getMintPrice() 读取的是 _tokenIdCounter,如果先自增后计算,用户看到的价格就是"下一个铸造者的价格"。例如当前 supply=0,正确价格为 0.01 ETH,但先自增 supply=1 后计算的单价是 0.011 ETH——多收了用户 0.001 ETH。
退款的重入风险源于 NFTCurvePrice 同时使用了两种防御策略:继承了 ReentrancyGuard(nonReentrant 修饰符),但在 _safeMint 和 call 退款之间有两次外部调用。虽然 nonReentrant 阻止了重入 mint() 函数,但如果退款调用触发了同一个合约的其他函数(如果有的话),仍可能被攻击。不过在本挑战的简单实现中,这条不是严重问题——nonReentrant 覆盖了整个 mint() 入口。
退款失败的根因是 push 模式的天生脆弱性:call 给不认识的合约地址时,对方可能在 receive() 或 fallback() 中故意 revert。对 EOA 地址,退款总是成功的;对合约地址,除非你知道对方实现了 receive(),否则不应假设能成功发送 ETH。
6. 如何解决陷阱
确保 getMintPrice() 在 _tokenIdCounter++ 之前调用——此时 _tokenIdCounter 的值就是"当前已铸造数量",公式 BASE_PRICE + (_tokenIdCounter * PRICE_INCREMENT) 计算的是本次铸造的正确价格。
对于退款策略,有两种改进方案:
方案 A:Pull over Push(推荐生产环境使用)
solidity
mapping(address => uint256) public pendingRefunds;
function mint() external payable nonReentrant {
uint256 price = getMintPrice();
if (msg.value < price) revert InsufficientPayment(msg.value, price);
// ... 铸造逻辑 ...
uint256 overpayment = msg.value - price;
if (overpayment > 0) {
pendingRefunds[msg.sender] += overpayment;
emit RefundPending(msg.sender, overpayment);
}
}
function claimRefund() external nonReentrant {
uint256 amount = pendingRefunds[msg.sender];
pendingRefunds[msg.sender] = 0;
(bool success,) = msg.sender.call{value: amount}("");
if (!success) revert RefundFailed();
}
Pull 模式将退款主动权交给用户——即使接收地址当前不工作,ETH 仍然记录在合约中,用户可以稍后再取。
方案 B:检查接收方类型
solidity
if (overpayment > 0) {
if (msg.sender.code.length == 0) {
// EOA:安全 push
(bool success,) = msg.sender.call{value: overpayment}("");
if (!success) revert RefundFailed();
} else {
// 合约:存储起来让用户 pull
pendingRefunds[msg.sender] += overpayment;
}
}
针对溢出风险,在构造函数或初始化时计算最大价格:
solidity
// Solidity 0.8+ 自动检查,但可以显式证明上界安全
// max price = 0.01 + 49 * 0.001 = 0.059 ETH,远小于 uint256 最大值
7. 技术要点
| 要点 | 说明 |
|---|---|
| 线性 bonding curve | price = BASE_PRICE + totalSupply * PRICE_INCREMENT |
| 退款机制 | 用户支付上限金额,按实际链上价格结算,余额自动退款 |
| Push vs Pull | Push 退款简单但脆弱(接收方可能拒绝);Pull 安全但需要用户主动 claim |
| 重入防护 | ReentrancyGuard (nonReentrant) 覆盖整个 mint() 入口 |
| 定价时序 | 在 _tokenIdCounter++ 之前调用 getMintPrice() |
| 曲线选择 | 线性、二次、指数、Sigmoid 四种曲线,各有不同的经济学效果 |
| 前端同步 | 需要实时监控 totalSupply 变化,在用户确认前刷新报价 |
| 价格上界 | 确保 MAX_SUPPLY * PRICE_INCREMENT 不溢出(Solidity 0.8+ 自动检查) |