Skip to content
On this page

L2-11: NFT Price Curve (Bonding Curve Dynamic Pricing)

1. Problem

Fixed-price NFT minting suffers from a severe fairness problem: bots (MEV bots) and gas bidders can always snatch up cheap NFTs the moment sales open, leaving ordinary users with no choice but to walk away -- or buy at inflated prices on the secondary market. This "first-come, first-served" fixed-price model turns NFT distribution into a pure gas war.

A Bonding Curve replaces the fixed price with a function-driven price: price = f(mintedCount). The first minter pays the lowest price; the 50th minter pays far more than the first. Early birds are rewarded with low prices because they bear the high risk of "the project might fail"; latecomers pay a premium because they can see that supply is already substantial and the project has gained some community validation. This self-regulating pricing mechanism creates a fairer distribution.

This challenge (refer to src/level2/NFTCurvePrice.sol) requires implementing an ERC-721 based NFT contract whose mint price increases linearly with the number already minted: price = BASE_PRICE + totalSupply * PRICE_INCREMENT.

2. Why

The Bonding Curve is not unique to NFTs -- it is the core primitive of "continuous market making" in DeFi. Uniswap's x * y = k is essentially a bonding curve (price changes with reserves); the Bancor protocol uses bonding curves to create fully collateralized elastic-supply tokens; Friend.tech's Key pricing is likewise based on a bonding curve.

At the learning level, NFTCurvePrice is the entry point for understanding "dynamic pricing": how do you derive a price from a state variable (totalSupply)? How do you defend against integer overflow across a theoretically unbounded price range? How do you handle overpayment refunds? The answers to these questions form the foundational mental model for DeFi pricing protocols.

The linear curve is the simplest Bonding Curve, but it is not the only one. Exponential curves (price = BASE * 2^(n/k)) create a strong FOMO effect -- extremely cheap early on, extremely expensive later. Quadratic curves (price = n^2 / k) reward the earliest supporters. Sigmoid curves are S-shaped -- the price gradient is steepest in the middle, creating maximum urgency around 50% supply. Understanding the differences between these curves is a prerequisite for making sound economic decisions in design.

3. Solution

The NFTCurvePrice contract (refer to src/level2/NFTCurvePrice.sol) inherits ERC721 and ReentrancyGuard, implementing linear pricing + automatic refunds:

solidity
contract NFTCurvePrice is ERC721, ReentrancyGuard {
    uint256 public constant BASE_PRICE = 0.01 ether;        // Mint price for the first one
    uint256 public constant PRICE_INCREMENT = 0.001 ether;  // Each additional mint raises the price by 0.001 ETH
    uint256 public constant MAX_SUPPLY = 50;
    uint256 private _tokenIdCounter;

    function getMintPrice() public view returns (uint256) {
        return BASE_PRICE + (_tokenIdCounter * PRICE_INCREMENT);
        // 0th (none minted yet): 0.01 ETH
        // 1st: 0.011 ETH
        // 49th: 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);

        // Automatic refund: if the user overpaid, return the difference
        uint256 overpayment = msg.value - price;
        if (overpayment > 0) {
            (bool success,) = msg.sender.call{value: overpayment}("");
            if (!success) revert RefundFailed();
            emit Refunded(msg.sender, overpayment);
        }
    }
}

Pricing Curve Comparison

Curve TypeFormula1st Price25th Price50th PriceUse Case
LinearBASE + n * STEP0.010.0350.059Simple fairness
Quadraticn^2 / k~0.0001~0.0625~0.25Extreme early rewards
SigmoidPiecewise function0.010.0350.29Mid-range urgency

Overpayment Refund Pattern

Refunds are key to the UX of Bonding Curve minting. Since the price changes in real time with supply, the quote the user sees on the frontend may differ from the on-chain price at the moment their transaction is actually included in a block (because someone else may have minted in between). The refund mechanism allows the user to send a "ceiling" amount, settle at the actual on-chain price, and have the excess automatically returned. This is why the design uses msg.value >= price rather than msg.value == price.

4. Pitfalls Encountered

  • Incorrect pricing timing: Calling getMintPrice() after _tokenIdCounter++ causes the user to pay the next person's price
  • Reentrancy risk in refunds: Executing the refund (an external call) after _safeMint (another external call) means there are two external call paths -- expanding the reentrancy surface
  • Refund failure rolling back the entire transaction: If the refund recipient is a malicious contract (that refuses to receive ETH), the RefundFailed error reverts the entire transaction -- the user gets neither the NFT nor the refund
  • Integer overflow: Although Solidity 0.8+ checks overflow by default, totalSupply * PRICE_INCREMENT can still overflow at extremely large supply values -- you must ensure the upper bound does not produce an overflowing price
  • Missing Pull over Push pattern: Refunding directly via call (push mode) rather than letting the user claim it themselves (pull mode) -- push mode can fail when the user is a contract

5. Why the Pitfalls Exist

Pricing timing is a classic ordering problem in state updates. getMintPrice() reads _tokenIdCounter; if you increment first and then calculate, the price the user sees is "the next minter's price." For example, if current supply=0, the correct price is 0.01 ETH, but after incrementing supply=1 first, the calculated unit price is 0.011 ETH -- overcharging the user by 0.001 ETH.

The reentrancy risk in refunds arises because NFTCurvePrice uses two defense strategies simultaneously: it inherits ReentrancyGuard (the nonReentrant modifier), but there are two external calls between _safeMint and the call refund. Although nonReentrant prevents re-entry into the mint() function, if the refund call triggers another function in the same contract (should one exist), it could still be exploited. In this challenge's simple implementation, however, this is not a serious issue -- nonReentrant covers the entire mint() entry point.

The root cause of refund failures is the inherent fragility of the push model: when you call an unknown contract address, the recipient may deliberately revert in its receive() or fallback(). For EOA addresses, refunds always succeed; for contract addresses, you should never assume you can successfully send ETH unless you know the recipient implements receive().

6. How to Resolve the Pitfalls

Ensure getMintPrice() is called before _tokenIdCounter++ -- at that point, the value of _tokenIdCounter is "the current number already minted," and the formula BASE_PRICE + (_tokenIdCounter * PRICE_INCREMENT) computes the correct price for this mint.

For the refund strategy, there are two improvement approaches:

Approach A: Pull over Push (recommended for production)

solidity
mapping(address => uint256) public pendingRefunds;

function mint() external payable nonReentrant {
    uint256 price = getMintPrice();
    if (msg.value < price) revert InsufficientPayment(msg.value, price);

    // ... minting logic ...

    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();
}

The Pull model gives the initiative to the user -- even if the receiving address is temporarily non-functional, the ETH remains recorded in the contract and the user can retrieve it later.

Approach B: Check the recipient type

solidity
if (overpayment > 0) {
    if (msg.sender.code.length == 0) {
        // EOA: safe to push
        (bool success,) = msg.sender.call{value: overpayment}("");
        if (!success) revert RefundFailed();
    } else {
        // Contract: store for user to pull
        pendingRefunds[msg.sender] += overpayment;
    }
}

For overflow risk, compute the maximum price during construction or initialization:

solidity
// Solidity 0.8+ auto-checks, but you can explicitly prove the upper bound is safe
// max price = 0.01 + 49 * 0.001 = 0.059 ETH, far below the uint256 maximum

7. Key Technical Points

PointDescription
Linear bonding curveprice = BASE_PRICE + totalSupply * PRICE_INCREMENT
Refund mechanismUser sends a ceiling amount, settles at actual on-chain price, balance auto-refunded
Push vs PullPush refund is simple but fragile (recipient may reject); Pull is safe but requires user to actively claim
Reentrancy protectionReentrancyGuard (nonReentrant) covers the entire mint() entry point
Pricing timingCall getMintPrice() before _tokenIdCounter++
Curve selectionLinear, quadratic, exponential, and sigmoid -- four curves with different economic effects
Frontend syncMust monitor totalSupply changes in real time and refresh the quote before user confirmation
Price upper boundEnsure MAX_SUPPLY * PRICE_INCREMENT does not overflow (Solidity 0.8+ checks automatically)

Built with AiAda