T19 · AMM:数学からコードへ
x·y=k は、コードではどう表現されるか?
- 練習する能力
- 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% を取る場所は三つです。
- 投入時に引く。 10 のうち 9.97 を計算に使い、0.03 は引き出しへ残します。
- 取得時に引く。 10 から 9.09 を計算し、そこから 0.027 を引きます。
- 最後に確認する。 積が前より少し大きいことを要求し、差を手数料とします。
似ていても結果は違います。重要なのは割合より、引いた分が残るか、外へ出るかです。
LP への自動配分と、規則の成立を決める違いです。
あなたが決める
交換へ 0.3% を加える方法を選びます。
結果を観察する
同取引を比較します。
100 万対 100 万へ、10000 を投入します。
| 方法 | 受取量 | 操作後の積 | LP の収益 | 不変条件 |
|---|---|---|---|---|
| 入力から引いて残す | 9871.58 | 増加 | シェア価値が自動増加 | 自然に成立 |
| 出力から引く | 9871.29 | 増加 | シェア価値が自動増加 | 成立するが偏る |
| 末尾確認のみ | 9900.99 | 不変 | なし | 唯一の防御 |
| 外へ送る | 9900.99 | 減少 | 外部記帳 | 必ず失敗 |
最初の二つの差は 0.29、後二つは同じに見えても、第四では毎回減り、一万取引で大きく失います。
手数料の位置は精度でなく、資産の帰属の問題です。 残せば LP、外へ出せば特定アドレスへ属し、プールは減ります。
手数料なしの曲線も、スライダーで観察します。
マーケットメーカーは価格を提示しません。価格は 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 もテスト失敗もなく、数万回後に減ります。
何と呼ぶか
交換前後で二資産の準備金の積を減らさない価格規則です。
価格は準備金比で決まり、人の提示は不要ですが、全取引が価格を動かします。一定価格の約定はありません。
記帳上の両側の数量です。
実際の残高とは別物です。直接送金で残高が大きくなり得ます。見積もりは準備金、決済は実残高からの逆算を使います。
曲線に沿って、一方を入れ他方を取ります。
本番では先に送ってから条件を確認し、フラッシュスワップを支えます。そのため再入防止が必須です。T28 で扱います。
入力から引き、プールへ残す部分です。
本質は徴収より、不変条件を少し増やすことです。全 LP のシェアへ帰属するので、配分ロジックは不要です。
プールの保有割合の証明です。
数量ではなく割合を記録し、償還は当時の二資産の比例分です。初期投入量ではなく、インパーマネントロスの原因です。Part A 第 15 章の直感を、コードで示しました。
取引後にプール価格が元からずれた幅です。
スリッページは平均約定価格のずれ、インパクトは終了後の新価格のずれです。同取引では後者が約二倍で、画面で混同するバグが多くあります。
実践
動くだけでなく、全数値を手計算で検証することが目標です。
テストトークンを二つ作ります。 単純な 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
三段階とも自分で検算します。
第 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 を数千回呼び、一取引で大きく取り、全工程が成功なので異常もありません。
ランダムに数千回操作し、積の非減少を確認する不変条件テストで防ぎます。人のレビューで見つけにくく、ファジングなら見つけられます。
直接送金すると、実残高が記帳準備金より大きくなります。
見積もりで残高を読むと事前送金で価格を操作でき、決済で準備金を読むと余剰を次の人が取れます。
見積もりは準備金、決済は残高で、同期箇所は一つです。 本番は余剰取得と強制同期の関数を別に持つこともあります。
変数を一つ変える
取引費用が下がり、裁定が容易になり、外部価格へよく追随します。
LP の収益は同量で六分の一になり、同じ収益には六倍の量が必要です。変動の大きい組では、手数料は損失を補うためのものです。
価格の相関が高い二ステーブルコインなどへ適します。低いほどよいのでなく、変動に合わせます。
両数量の和を一定にします。
直線なので、取引量によらず 1:1 で、スリッページがありません。
しかし実価格がずれると、裁定者が安い側をすべて買い切ります。定積の双曲線は軸へ接せず、準備金がゼロになりません。
実際のステーブルプールは、1:1 付近で直線に近く、離れると双曲線へ曲がる中間を使います。
式は同じでも数値を再計算し、中間乗算があふれやすくなります。
6 桁の最小単位は百万分の一で、丸めによる絶対的影響が 18 桁より大きくなります。
Lab で故意に変えた理由です。精度は頻出事故源ですが、紙の導出には現れません。
保有開始時刻、期間中の累積、取り分の記帳が必要です。
一シェア当たり累積費用と、入退時のスナップショットで実装できますが、複雑さと攻撃面が増え、時間配分の境界に注意が必要です。
残す設計は、配分コードなしで正しい比例配分を実現します。 良い機構設計の例です。
持ち帰る問い
誰が流動性を提供しますか?LP は二資産と比例償還の証明を交換し、損失を負って 0.3% の蓄積を得ます。プールを見る際は、両者の比を確認します。
誰が支払いますか?交換する人です。収益は無からでなく取引者から来ます。ゼロ手数料 DEX なら、LP は何で損失を補いますか?
誰がリスクを負いますか?LP は価格ずれの損失、取引者はスリッページと先回りを負い、コードであるプロトコル自体はほぼ負いません。T24 で再び使います。
動機は何ですか?裁定者は差益で価格を外部へ戻します。なければ AMM 価格は誤ったままです。 雇わなくても、利益が出る必要があります。
章末チェック
入力側で残すと、積の増分が全 LP へ比例帰属し、配分コードは不要です。
出力側は近似でも蓄積が価格をずらし、少量準備金で丸めが難しくなります。
外へ送ると積が毎回減り、唯一の安全網を失います。
理由は二つです。
課税トークンなら 1000 が 970 になります。引数を信じれば外部コントラクトを信じることになります。
実行順序も重要です。先に送り、後で確認するフラッシュスワップでは、その時点の実残高が唯一の信頼できる事実です。
資金判断は自分で読める状態に基づき、相手が渡す数値に基づけません。
小利益を無限反復できる穴です。数万取引で大きく減り、revert やエラーなしで単体テストを通ります。
ランダム操作で積の非減少と、預入直後の引き出しで増えないことを確認します。
AMM の必須項目で、加点項目ではありません。
違います。
スリッページは平均約定価格、インパクトは終了後の新価格の、開始価格からのずれです。
平均は曲線の途中なので、定積では後者が約二倍です。
混同すると許容できそうな表示でも保護で失敗したり、その逆が起きたりします。
片側準備金の約 73.2%、片側 100 万なら約 73.2 万です。
資金は消えません。 別資産と交換して、ずれた価格のポジションを持ち、裁定で戻ると相当分を失います。
費用は必要な資金量と、回復時の純損失です。その前に誤価格で別コントラクトからそれ以上を取れれば、攻撃が成立します。
T21 で答えます。必要元本、一取引で可能か、価格回復時の損失という三点を計算します。
要点
定数積のあらゆる性質は、数十行のコードに対応する箇所がある。