Crypto OS
Technical Crypto OS第4段階 · DeFi 開発

T19 · AMM:数学からコードへ

x·y=k は、コードではどう表現されるか?

この章を 3D で体験する →
練習する能力
BuilderFinancial Literacy
実践
最小限の AMM を実装し、スリッページ、手数料、流動性の追加と引き出しの数値が正しいかテストする。
AI Lab
AI に手数料込みの見積もり式を導出させ、テスト値で手数料の適用位置が正しいか自分で検算する。

現実の問題

注文板やマーケットメーカーなしに、二種類のトークンを交換する機能が必要です。

有名な 5 文字の式を見つけ、10 行ほどで書けました。

// reserveIn と reserveOut を持つプールへ amountIn を投入
uint256 k = reserveIn * reserveOut;
uint256 newReserveIn = reserveIn + amountIn;
uint256 amountOut = reserveOut - k / newReserveIn;

1000 から 996 が返り、妥当に見えます。

レビューで聞かれます。手数料はどこへ加えますか?

入力から 0.3% を引く、出力から引く、式を変えず末尾で積の非減少を確認する、という三つが正しく見えました。

同取引でも数ベーシスポイント違い、一つはプールを徐々に空にする攻撃を許します。単体テストは、1000 の交換結果だけを測り、交換後の残存資産を測らないので成功します。

式は 5 文字でも、実装は四通りあり、正しいものは一つです。 各文字がコードのどこへ現れるかを確認します。

思考実験

コードを忘れ、二つの引き出しを持つ鍵付き棚を考えます。

リンゴ 100、オレンジ 100 があり、規則は一つです。

一方へ入れ、他方から取れます。ただし、操作後の数量の積を減らしてはいけません。

オレンジ 10 を入れると 110 になります。積 10000 を 110 で割ると約 90.9 なので、リンゴは少なくともそれだけ残し、取れるのは約 9.09 です。

10 入れても、9.09 しか取れません。 手数料でなく乗算の規則による差で、多く買うほど後のリンゴは高くなります。

管理者が 0.3% を取る場所は三つです。

  1. 投入時に引く。 10 のうち 9.97 を計算に使い、0.03 は引き出しへ残します。
  2. 取得時に引く。 10 から 9.09 を計算し、そこから 0.027 を引きます。
  3. 最後に確認する。 積が前より少し大きいことを要求し、差を手数料とします。

似ていても結果は違います。重要なのは割合より、引いた分が残るか、外へ出るかです。

LP への自動配分と、規則の成立を決める違いです。

あなたが決める

交換へ 0.3% を加える方法を選びます。

結果を観察する

同取引を比較します。

100 万対 100 万へ、10000 を投入します。

方法受取量操作後の積LP の収益不変条件
入力から引いて残す9871.58増加シェア価値が自動増加自然に成立
出力から引く9871.29増加シェア価値が自動増加成立するが偏る
末尾確認のみ9900.99不変なし唯一の防御
外へ送る9900.99減少外部記帳必ず失敗

最初の二つの差は 0.29、後二つは同じに見えても、第四では毎回減り、一万取引で大きく失います。

手数料の位置は精度でなく、資産の帰属の問題です。 残せば LP、外へ出せば特定アドレスへ属し、プールは減ります。

手数料なしの曲線も、スライダーで観察します。

受け取る数量
45,455 枚
約定価格の市場価格との差
10.00%
取引後のプール価格の上昇
21.00%

マーケットメーカーは価格を提示しません。価格は x·y=k の曲線で決まります。プールが浅く購入額が大きいほど市場価格との差が広がり、その差は裁定取引者と流動性提供者の利益になります。

浅いほど同額の偏りが大きく、同プールでも多く買うほど平均約定価格が悪くなります。

曲線の定義であり、バグではありません。また、価格を特定の位置へ動かす費用を計算できます。Lab で求めます。

モデルを作る

最小プールの中心は四関数です。import なしで読めます。

第一は見積もりです。 最重要のコードです。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

/// 入力側の 0.3% 手数料をプールへ残す
function getAmountOut(
    uint256 amountIn,
    uint256 reserveIn,
    uint256 reserveOut
) pure returns (uint256 amountOut) {
    require(amountIn > 0, "ZERO_INPUT");
    require(reserveIn > 0 && reserveOut > 0, "NO_LIQUIDITY");

    uint256 amountInWithFee = amountIn * 997;
    uint256 numerator = amountInWithFee * reserveOut;
    uint256 denominator = reserveIn * 1000 + amountInWithFee;

    amountOut = numerator / denominator;   // 切り捨て、余りはプールへ
}

三つの細部が、事故の原因になります。

  • 先に乗算、後に除算します。 numerator を完成させてから割り、途中で除算して精度を失わないようにします。
  • 分母の 997 は誤記ではありません。 分子と分母を 1000 倍にして、浮動小数点なしで割合を表します。
  • 最後は切り捨てです。 理論値を超えない整数を渡し、余りは残します。

第二は交換です。 見積もりは純粋でも、交換は実残高を扱います。

function swap(uint256 amount0Out, uint256 amount1Out, address to) external {
    require(amount0Out > 0 || amount1Out > 0, "ZERO_OUTPUT");
    require(amount0Out < reserve0 && amount1Out < reserve1, "NOT_ENOUGH");

    if (amount0Out > 0) _safeTransfer(token0, to, amount0Out);
    if (amount1Out > 0) _safeTransfer(token1, to, amount1Out);

    uint256 balance0 = IERC20(token0).balanceOf(address(this));
    uint256 balance1 = IERC20(token1).balanceOf(address(this));

    // 呼び出し元の引数でなく実残高から受取量を逆算
    uint256 amount0In = balance0 > reserve0 - amount0Out ? balance0 - (reserve0 - amount0Out) : 0;
    uint256 amount1In = balance1 > reserve1 - amount1Out ? balance1 - (reserve1 - amount1Out) : 0;
    require(amount0In > 0 || amount1In > 0, "NO_INPUT");

    // 0.3% 調整後の積が減らないこと
    uint256 adjusted0 = balance0 * 1000 - amount0In * 3;
    uint256 adjusted1 = balance1 * 1000 - amount1In * 3;
    require(adjusted0 * adjusted1 >= reserve0 * reserve1 * 1000 * 1000, "K");

    reserve0 = balance0;
    reserve1 = balance1;
}

amount0In は残高から逆算し、引数ではありません。呼び出し元の主張でなく、自分で数えることが信頼の境界です。

第三はシェアです。 流動性追加時の発行量です。

function _sharesFor(uint256 amount0, uint256 amount1) internal view returns (uint256) {
    if (totalShares == 0) {
        // 初回は両数量の積の平方根をシェアとする
        return _sqrt(amount0 * amount1) - MINIMUM_LIQUIDITY;
    }
    uint256 s0 = (amount0 * totalShares) / reserve0;
    uint256 s1 = (amount1 * totalShares) / reserve1;
    return s0 < s1 ? s0 : s1;   // 小さい方を採用。余剰投入は寄付になる
}

MINIMUM_LIQUIDITY は、初回の一部を永久に固定するもので、誰にも属しません。実例の攻撃を防ぐためです。

第四は、誤りやすい丸め方向です。

計算対象方向逆だと何が起きるか
交換出力切り捨て毎回余分に渡し、減り続ける
LP シェア発行切り捨て新 LP が余分を得て旧 LP を希薄化
シェア償還のトークン切り捨て出る人が余分を取り、残る人が負担
不変条件以上、非減少毎回少しずつ削れる

丸めは毎回、プールに有利にします。

好みではありません。逆なら反復可能な小利益の穴になり、revert もテスト失敗もなく、数万回後に減ります。

何と呼ぶか

Constant Product定積

交換前後で二資産の準備金の積を減らさない価格規則です。

価格は準備金比で決まり、人の提示は不要ですが、全取引が価格を動かします。一定価格の約定はありません。

Reserve準備金

記帳上の両側の数量です。

実際の残高とは別物です。直接送金で残高が大きくなり得ます。見積もりは準備金、決済は実残高からの逆算を使います。

Swap交換

曲線に沿って、一方を入れ他方を取ります。

本番では先に送ってから条件を確認し、フラッシュスワップを支えます。そのため再入防止が必須です。T28 で扱います。

Fee手数料

入力から引き、プールへ残す部分です。

本質は徴収より、不変条件を少し増やすことです。全 LP のシェアへ帰属するので、配分ロジックは不要です。

LP Token流動性シェア

プールの保有割合の証明です。

数量ではなく割合を記録し、償還は当時の二資産の比例分です。初期投入量ではなく、インパーマネントロスの原因です。Part A 第 15 章の直感を、コードで示しました。

Price Impact価格インパクト

取引後にプール価格が元からずれた幅です。

スリッページは平均約定価格のずれ、インパクトは終了後の新価格のずれです。同取引では後者が約二倍で、画面で混同するバグが多くあります。

実践

実践最小 AMM を作り、スリッページ、手数料、流動性追加と引き出しを数値検証するSolidity + 任意のコントラクトテストフレームワーク0 円、すべてローカルネットワーク

動くだけでなく、全数値を手計算で検証することが目標です。

テストトークンを二つ作ります。 単純な ERC-20 に mint を付け、桁数は 18 と 6 に変えます。後で問題を発見するためです。

四関数を実装します。addLiquidity、removeLiquidity、getAmountOut、swap です。

数学ライブラリを使わず、sqrt をニュートン法で書き、全除算に気付きます。

第一のテストは手数料の位置です。

100 万対 100 万へ 10000 を入れ、式を手計算し、整数の 9871 に厳密一致するか確認します。近似値で済ませません。

997 を 1000 にして手数料をゼロにし、9900.99 に対応する整数を確認します。両方一致すれば位置が正しいと分かります。

第二は不変条件の非減少です。

ランダム方向、金額で 1000 交換し、毎回積が前以上か確認します。

丸め方向の誤りを捕捉します。 一箇所を切り上げにし、何回目で失敗するか見ます。

第三は預入直後の引き出しで増えないことです。

同アドレスで addLiquidity 後に removeLiquidity し、両数量が預入以下か確認します。

A が預け、B が大口交換し、A が引き出す場合も試します。両数量は変わりますが、当時価格の総価値を、預入価値と損失で比較し、表にします。

攻撃費用を計算します。 本章の中心です。

価格を m 倍へ動かす投入額を求めます。

準備金 x、y、価格 p = y / x
m 倍の後:x' = x / sqrt(m)、y' = y * sqrt(m)
必要な y = y' - y = y * (sqrt(m) - 1)

m = 2   → y 側の約 41.4%
m = 4   → 100%
m = 100 → 900%

100 万対 100 万へ 414214 を入れ、約二倍になるか試します。

瞬間価格をオラクルに使うコントラクトなら、二倍への操作費用はいくらですか? 記録してください。T21 の土台になります。

ローカルのみで、実資金を接続しません。 実資金は卒業課題で、安全性の第五段階を通過してから使います。

AI Lab

AI LabAI に手数料付き見積もりを導かせ、数値で位置の誤りを検証するLevel 2 · AI Copilot

三段階とも自分で検算します。

第 1 段階:
x*y=k から、入力の 0.3% を引いてプールへ残す見積もり式を導いてください。
全導出と、整数のみの Solidity 実装を示してください。

第 2 段階:
正確な amountOut を得るための amountIn の逆関数を示し、
丸め方向が正方向と逆になる理由を説明してください。

第 3 段階:
全精度損失箇所を列挙し、誤差の方向と利益を得る側を説明してください。

第二に価値があります。逆関数は丸めも逆です。100 を得る入力を切り上げないと一単位不足で失敗します。モデルは式だけ反転し、1 を足し忘れがちです。

答えをコードでなく電卓で検算します。コードでは誤りもそのまま実行してしまうからです。

AI の回答後、自分で必ず検証すること

  • 手数料係数が分子と分母の両方にある。分子だけは頻出の誤りです
  • 100 万対 100 万、入力 10000 を手計算し、自分の実装と一の位まで一致する
  • 手数料ゼロで元の式へ戻る
  • 出力から入力を求める逆関数は切り上げる。正方向と逆です
  • 途中の除算で精度を失っていない
  • 価格インパクトとスリッページを同じにしていない

実例

初回流動性の価格決定権繰り返し発生

空プールの最初の人が、投入比で一方的に初期価格を決めます。

シェア総量が例えば 1 と小さい初期実装では、攻撃者が直接大量送金し、一シェア価値を上げます。後の小口預入のシェアがゼロへ丸められ、資金だけ入ります。

MINIMUM_LIQUIDITY が修正です。初回の一部を償還不能にし、総量の操作不能な下限を作ります。

なければ追加し、攻撃を再現するテストを書きます。

送金で手数料を引くトークン継続的に存在

transfer で一部を引くトークンは、1000 を送っても 970 しか届きません。

残高でなく引数を信じると、条件を通っても資金が不足します。

残高から逆算する理由です。 swap の amount0In の計算が対応します。手数料付きトークンを作り、実測します。悪意ある道具を自作して検証すべき場面です。

丸め方向を逆にした典型的な脆弱性

除算を切り上げるか、減算で一単位少なく引きます。

一回の利益は小さく、無限に繰り返せます。 数 wei を数千回呼び、一取引で大きく取り、全工程が成功なので異常もありません。

ランダムに数千回操作し、積の非減少を確認する不変条件テストで防ぎます。人のレビューで見つけにくく、ファジングなら見つけられます。

残高と準備金を混同したよくある実装ミス

直接送金すると、実残高が記帳準備金より大きくなります。

見積もりで残高を読むと事前送金で価格を操作でき、決済で準備金を読むと余剰を次の人が取れます。

見積もりは準備金、決済は残高で、同期箇所は一つです。 本番は余剰取得と強制同期の関数を別に持つこともあります。

変数を一つ変える

もし手数料を 0.3% から 0.05% に下げる

取引費用が下がり、裁定が容易になり、外部価格へよく追随します。

LP の収益は同量で六分の一になり、同じ収益には六倍の量が必要です。変動の大きい組では、手数料は損失を補うためのものです。

価格の相関が高い二ステーブルコインなどへ適します。低いほどよいのでなく、変動に合わせます。

もし積の規則を和へ変える

両数量の和を一定にします。

直線なので、取引量によらず 1:1 で、スリッページがありません。

しかし実価格がずれると、裁定者が安い側をすべて買い切ります。定積の双曲線は軸へ接せず、準備金がゼロになりません。

実際のステーブルプールは、1:1 付近で直線に近く、離れると双曲線へ曲がる中間を使います。

もし桁数が 18 と 6 で異なる

式は同じでも数値を再計算し、中間乗算があふれやすくなります。

6 桁の最小単位は百万分の一で、丸めによる絶対的影響が 18 桁より大きくなります。

Lab で故意に変えた理由です。精度は頻出事故源ですが、紙の導出には現れません。

もし手数料を残さず、LP が別に引き出せるようにする

保有開始時刻、期間中の累積、取り分の記帳が必要です。

一シェア当たり累積費用と、入退時のスナップショットで実装できますが、複雑さと攻撃面が増え、時間配分の境界に注意が必要です。

残す設計は、配分コードなしで正しい比例配分を実現します。 良い機構設計の例です。

持ち帰る問い

8
誰が流動性を提供するか?

誰が流動性を提供しますか?LP は二資産と比例償還の証明を交換し、損失を負って 0.3% の蓄積を得ます。プールを見る際は、両者の比を確認します。

5
誰が支払っているか?

誰が支払いますか?交換する人です。収益は無からでなく取引者から来ます。ゼロ手数料 DEX なら、LP は何で損失を補いますか?

9
誰がリスクを負うか?

誰がリスクを負いますか?LP は価格ずれの損失、取引者はスリッページと先回りを負い、コードであるプロトコル自体はほぼ負いません。T24 で再び使います。

10
インセンティブは何か?

動機は何ですか?裁定者は差益で価格を外部へ戻します。なければ AMM 価格は誤ったままです。 雇わなくても、利益が出る必要があります。

章末チェック

要点

定数積のあらゆる性質は、数十行のコードに対応する箇所がある。

この章の実践課題を終えたらチェックしてください進捗をすべて見る

このページの内容