Appearance
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 Type | Formula | 1st Price | 25th Price | 50th Price | Use Case |
|---|---|---|---|---|---|
| Linear | BASE + n * STEP | 0.01 | 0.035 | 0.059 | Simple fairness |
| Quadratic | n^2 / k | ~0.0001 | ~0.0625 | ~0.25 | Extreme early rewards |
| Sigmoid | Piecewise function | 0.01 | 0.035 | 0.29 | Mid-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
RefundFailederror 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_INCREMENTcan 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
| Point | Description |
|---|---|
| Linear bonding curve | price = BASE_PRICE + totalSupply * PRICE_INCREMENT |
| Refund mechanism | User sends a ceiling amount, settles at actual on-chain price, balance auto-refunded |
| Push vs Pull | Push refund is simple but fragile (recipient may reject); Pull is safe but requires user to actively claim |
| Reentrancy protection | ReentrancyGuard (nonReentrant) covers the entire mint() entry point |
| Pricing timing | Call getMintPrice() before _tokenIdCounter++ |
| Curve selection | Linear, quadratic, exponential, and sigmoid -- four curves with different economic effects |
| Frontend sync | Must monitor totalSupply changes in real time and refresh the quote before user confirmation |
| Price upper bound | Ensure MAX_SUPPLY * PRICE_INCREMENT does not overflow (Solidity 0.8+ checks automatically) |