Skip to content
On this page

L2-11: NFT Price Curve(Bonding Curve 動的価格設定)

1. 問題

固定価格のNFT発行には深刻な不公平性の問題があります。ボット(MEVボット)やガス入札者は販売開始と同時に安価なNFTを奪い取り、一般ユーザーは諦めるか二次市場で高値で購入するしかありません。この「先着順」の固定価格モデルは、NFTの配布を純粋なガス戦争に変えてしまいます。

Bonding Curve(ボンディングカーブ)は、固定価格を price = f(mintedCount) という関数ベースの価格設定に置き換えます。最初の発行者が最低価格を支払い、50番目の発行者は最初の発行者よりもはるかに高い価格を支払います。アーリーバードは「プロジェクトが失敗するかもしれない」という高いリスクを負うため低価格で報われ、後発者は供給量がすでに多くプロジェクトにある程度のコミュニティ検証があることを確認できるためプレミアムを支払います。この自己調整型の価格決定メカニズムが、より公平な配布を実現します。

本チャレンジ(src/level2/NFTCurvePrice.sol 参照)では、発行済み数量に応じて線形に価格が上昇するERC-721ベースのNFTコントラクトを実装します:price = BASE_PRICE + totalSupply * PRICE_INCREMENT

2. 理由

Bonding CurveはNFTに限ったものではなく、DeFiにおける「連続マーケットメイキング」の中核的プリミティブです。Uniswapの x * y = k は本質的にボンディングカーブであり(リザーブに応じて価格が変動)、Bancorプロトコルはボンディングカーブを用いて完全担保の弾力的供給トークンを作成し、Friend.techのKey価格もボンディングカーブに基づいています。

学習面では、NFTCurvePriceは「動的価格設定」を理解するための入口となります。状態変数(totalSupply)からどのように価格を導出するか?理論的に無限の価格値域において整数オーバーフローをどのように防ぐか?過払いの返金をどのように処理するか?これらの問いへの答えが、DeFi価格設定プロトコルの基礎的な思考モデルを形成します。

線形カーブは最もシンプルなBonding Curveですが、唯一のものではありません。指数カーブ(price = BASE * 2^(n/k))は強いFOMO効果を生み出し、初期は極めて安価で後期は極めて高価になります。二次カーブ(price = n^2 / k)は最も初期のサポーターに報います。シグモイドカーブは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;  // 1つ発行されるごとに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極初期への報酬
シグモイド区分関数0.010.0350.29中盤の緊迫感

過払い返金(Overpayment Refund)パターン

返金はBonding Curve発行におけるUXの鍵です。価格は供給量に応じてリアルタイムに変動するため、ユーザーがフロントエンドで見た見積もりと、トランザクションが実際にブロックに取り込まれた時点のオンチェーン価格は異なる可能性があります(間に他の人が発行した可能性があるため)。返金メカニズムにより、ユーザーは「上限」金額を送金し、実際のオンチェーン価格で決済され、余剰分が自動的に返金されます。これが msg.value == price ではなく msg.value >= price を使用する設計理由です。

4. 遭遇した落とし穴

  • 価格計算のタイミング誤り: _tokenIdCounter++ の後に getMintPrice() を呼び出すと、ユーザーは次の人の価格を支払うことになる
  • 返金のリエントランシリスク: _safeMint(外部呼び出し)の後に返金(もう一つの外部呼び出し)を実行すると、合計2つの外部呼び出し経路が存在し、リエントランシ面が拡大する
  • 返金失敗がトランザクション全体をロールバック: 返金受取先が悪意あるコントラクト(ETH受取拒否)の場合、RefundFailedエラーがトランザクション全体を巻き戻す — ユーザーはNFTも返金も得られない
  • 整数オーバーフロー: Solidity 0.8+はデフォルトでオーバーフローをチェックするが、供給量が極めて大きい場合 totalSupply * PRICE_INCREMENT は依然オーバーフローし得る — 上限がオーバーフロー価格を生まないことを保証する必要がある
  • Pull over Pushパターンの欠如: ユーザー自身に請求させる(pullモード)のではなく call で直接返金(pushモード)する — pushモードはユーザーがコントラクトの場合に失敗する可能性がある

5. 落とし穴の原因

価格計算のタイミングは状態更新における古典的な順序問題です。getMintPrice()_tokenIdCounter を読み取ります。先にインクリメントしてから計算すると、ユーザーが見る価格は「次の発行者の価格」になります。例えば現在supply=0で正しい価格が0.01 ETHのところ、先にsupply=1にインクリメントして計算した単価は0.011 ETHとなり、ユーザーに0.001 ETH過剰請求することになります。

返金のリエントランシリスクは、NFTCurvePrice が2つの防御戦略を同時に使用していることに起因します。ReentrancyGuardを継承(nonReentrant修飾子)していますが、_safeMintcall 返金の間に2回の外部呼び出しがあります。nonReentrantmint() 関数への再入を防ぎますが、返金呼び出しが同じコントラクトの他の関数(もしあれば)をトリガーした場合、依然として攻撃される可能性があります。ただし、本チャレンジのシンプルな実装では、これは深刻な問題ではありません — nonReentrantmint() エントリ全体をカバーしています。

返金失敗の根本原因はpushモードの本質的な脆弱性です。未知のコントラクトアドレスに call する場合、相手は receive()fallback() で意図的にrevertする可能性があります。EOAアドレスへの返金は常に成功しますが、コントラクトアドレスに対しては、相手が receive() を実装していることを知っていない限り、ETHの送信成功を前提とすべきではありません。

6. 落とし穴の解決方法

getMintPrice()_tokenIdCounter++ の前に呼び出すようにします。この時点では _tokenIdCounter の値は「現在の発行済み数量」であり、式 BASE_PRICE + (_tokenIdCounter * PRICE_INCREMENT) は今回の発行の正しい価格を計算します。

返金戦略については、2つの改善案があります:

案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. 技術的要点

要点説明
線形ボンディングカーブprice = BASE_PRICE + totalSupply * PRICE_INCREMENT
返金メカニズムユーザーが上限金額を支払い、実際のオンチェーン価格で決済、残高は自動返金
Push vs PullPush返金はシンプルだが脆弱(受取側が拒否する可能性あり);Pullは安全だがユーザーの能動的な請求が必要
リエントランシ対策ReentrancyGuardnonReentrant)が mint() エントリ全体をカバー
価格計算タイミング_tokenIdCounter++ より前に getMintPrice() を呼び出す
カーブ選択線形、二次、指数、シグモイドの4種類、それぞれ異なる経済効果を持つ
フロントエンド同期totalSupply の変化をリアルタイム監視し、ユーザー確認前に見積もりを更新する必要がある
価格上限MAX_SUPPLY * PRICE_INCREMENT がオーバーフローしないことを保証(Solidity 0.8+ が自動チェック)

Built with AiAda