Skip to content
On this page

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 * STEP0.010.0350.059简单公平
二次n^2 / k~0.0001~0.0625~0.25极早期奖励
Sigmoid分段函数0.010.0350.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 同时使用了两种防御策略:继承了 ReentrancyGuardnonReentrant 修饰符),但在 _safeMintcall 退款之间有两次外部调用。虽然 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 curveprice = BASE_PRICE + totalSupply * PRICE_INCREMENT
退款机制用户支付上限金额,按实际链上价格结算,余额自动退款
Push vs PullPush 退款简单但脆弱(接收方可能拒绝);Pull 安全但需要用户主动 claim
重入防护ReentrancyGuard (nonReentrant) 覆盖整个 mint() 入口
定价时序_tokenIdCounter++ 之前调用 getMintPrice()
曲线选择线性、二次、指数、Sigmoid 四种曲线,各有不同的经济学效果
前端同步需要实时监控 totalSupply 变化,在用户确认前刷新报价
价格上界确保 MAX_SUPPLY * PRICE_INCREMENT 不溢出(Solidity 0.8+ 自动检查)

Built with AiAda