Математика AMM: пул, который сам назначает цену
В прошлом уроке цену приносили снаружи: протокол спрашивал оракула и верил ему на слово. Теперь цена рождается внутри контракта, и рождает её одно равенство. Из него выведем всё: сколько токенов вернёт своп, откуда берётся проскальзывание, за что платят поставщику ликвидности, почему он всё равно может остаться в минусе и как пул умеет выдавать деньги до оплаты.
Почему в цепи не прижился стакан
Стакан из урока 44 устроен отлично, но переносить его в блокчейн как есть больно.
Котировки нужно постоянно двигать. Маркетмейкер обновляет цены десятки раз в минуту, а в цепи каждое обновление это транзакция с газом. Даже если газ дешёвый, скорость всё равно ограничена временем блока.
Заявка живёт в чужом ритме. Пока твоя транзакция ждёт включения, цена ушла, и заявка стала подарком для того, кто быстрее.
Ликвидность требует постоянной работы. Стакан пуст, пока в нём никто не стоит, а стоять готовы только профессионалы с ботами.
Отсюда идея, которая и сделала DeFi: пусть цена будет функцией, а ликвидность пассивной. Никто не двигает котировки, они выводятся из того, сколько токенов лежит в пуле. Кто угодно может внести токены и получать долю комиссий, не следя за рынком.
Это не запрет на стаканы в блокчейне. Например, OpenBook в Solana хранит и сопоставляет заявки в цепи. DEX описывает устройство обмена, а не формулу цены: децентрализованная биржа может использовать стакан, AMM или несколько механизмов. Дальше разбираем конкретно AMM с постоянным произведением, а не все DEX сразу.
Инвариант: одно равенство вместо котировок
Пул держит резервы двух токенов и подчиняется инварианту: произведение резервов не должно уменьшаться.
x * y = k
Здесь x и y это резервы, а k это то, что обязано сохраняться при обмене. Отсюда сразу три следствия.
Цена это отношение резервов. Спотовая цена token0 в token1 равна y / x. Никакого другого источника цены нет.
Пул никогда не опустеет. Чтобы забрать весь y, нужно положить бесконечно много x. Кривая асимптотически прижимается к осям и не пересекает их.
Чем больше сделка, тем хуже цена. Двигаясь по гиперболе, ты уходишь от касательной в точке старта. Это влияние самой сделки на цену, или price impact.
Поиграй с этим сам: двигай размер свопа, включай и выключай комиссию, смотри, куда едет точка резервов и что происходит с k.
Формула свопа, выведенная по шагам
Пусть в пуле x и y, ты кладёшь dx и хочешь получить dy. Инвариант требует:
(x + dx) * (y - dy) = x * y
Раскроем и выразим dy:
dy = (dx * y) / (x + dx)
Это формула без комиссии. Теперь добавим её. В нашей модели комиссия снимается со входа: в формулу идёт не весь dx, а его часть после вычета. Обозначим долю, которая остаётся после комиссии, через (10000 - fee) / 10000 в базисных пунктах:
/// Выход свопа по формуле Uniswap v2.
///
/// `dy = (dx * (1 - f) * y) / (x + dx * (1 - f))`, вся дробь считается
/// целочисленно и с округлением вниз: остаток от деления достаётся пулу.
pub fn amount_out(
amount_in: u128,
reserve_in: u128,
reserve_out: u128,
fee_bps: Bps,
) -> Result<u128, AmmError> {
if amount_in == 0 {
return Err(AmmError::ZeroAmount);
}
if reserve_in == 0 || reserve_out == 0 {
return Err(AmmError::EmptyPool);
}
let net_in = amount_in
.checked_mul(BPS_DENOM - u128::from(fee_bps))
.ok_or(AmmError::Overflow)?;
let numerator = net_in.checked_mul(reserve_out).ok_or(AmmError::Overflow)?;
let denominator = reserve_in
.checked_mul(BPS_DENOM)
.ok_or(AmmError::Overflow)?
.checked_add(net_in)
.ok_or(AmmError::Overflow)?;
Ok(numerator / denominator)
}
Три детали, каждая из которых в реальном контракте стоит денег.
Целочисленность. Никаких дробей: в контракте их нет, и попытка эмулировать их через число с плавающей точкой означала бы, что разные узлы могут получить разные ответы.
Округление вниз. Остаток от деления остаётся в пуле. Это не жадность, а безопасность: округление в другую сторону дало бы способ по копейке выносить резервы циклом мелких свопов.
Проверки переполнения. checked_mul тут не перестраховка. Произведение резервов на десять тысяч это ровно то место, где в реальных контрактах ловили переполнения.
Цена на экране и цена твоего свопа
Возьми пул со 100 X и 100 Y, как в разборе ценообразования DEX-пулов. Интерфейс показывает 1 Y за 1 X. Ты продаёшь 10 X. Пока уберём комиссию и округление: пул вернёт 100 × 10 / 110 ≈ 9.090909 Y.
На одном обмене появились три разных цены:
| Что считаем | Y за 1 X | Что означает число |
|---|---|---|
| Спот до сделки | 1 | Отношение исходных резервов |
| Средняя цена исполнения | 0.909091 | Весь полученный Y, делённый на внесённые 10 X |
| Спот после сделки | 0.826446 | Новое отношение резервов: (100 − 9.090909) / 110 |
Спот после сделки не означает, что все твои токены продались по последней цене. Ты прошёл участок кривой, поэтому средняя цена находится между начальной и конечной. Следующий своп начнётся уже с новых резервов, а не с исходной котировки.
Комиссия меняет и выход, и состояние
Для наглядности возьмём большую учебную комиссию 10%. Это условие примера, не рекомендуемая настройка реального пула. Из входа 10 X формула учитывает 9 X, поэтому выход равен 100 × 9 / 109 ≈ 8.256881 Y.
Но после обмена в нашем пуле лежат 110 X и примерно 91.743119 Y. Все десять входных токенов пришли в резерв; один из них достался поставщикам как комиссия. Записать в резерв только 109 X означало бы перейти к другой модели, в которой комиссия выведена из торговых резервов. Смешивать эти два способа учёта в расчётах нельзя.
Теперь продай ещё 10 X. Формулу нужно вызвать заново с резервами после первой сделки. Второй выход составит примерно 6.938555 Y, а не столько же, сколько первый.
Обмен из исходного пула 100 X / 100 Y | Суммарный выход Y | Резерв X после | Резерв Y после |
|---|---|---|---|
Один своп 20 X | 15.254237 | 120 | 84.745763 |
Два последовательных свопа по 10 X | 15.195436 | 120 | 84.804564 |
Здесь единый своп даёт чуть больше: комиссия первой части уже осталась в пуле и повлияла на цену второй. Условия сравнения важны: один и тот же пул, никто больше не торгует, комиссию учитываем как в Uniswap v2, округление пока игнорируем. Без комиссии два последовательных свопа дают тот же суммарный выход, что один на общий объём. Само равенство x*y=k не доказывает правило «один своп всегда выгоднее двух»; сетевые расходы и правила конкретного протокола нужно считать отдельно.
Цена исполнения и допуск проскальзывания
Пул миллион на миллион, комиссия 0.3%:
| Вход | Выход | Цена исполнения | Отклонение от спота |
|---|---|---|---|
| 1 000 | 996 | 0.9960 | 40 bps |
| 10 000 | 9 871 | 0.9871 | 129 bps |
| 100 000 | 90 661 | 0.9066 | 933 bps |
| 250 000 | 199 519 | 0.7981 | 2019 bps |
| 500 000 | 332 665 | 0.6653 | 3346 bps |
Смотри на первую строку: своп в 0.1% резерва стоит 40 базисных пунктов, из которых 30 это комиссия и только 10 кривизна. А своп в половину резерва теряет треть стоимости. Отсюда практический вывод, который делают все, кто торгует всерьёз: глубина пула это и есть его цена.
Отклонение от спота полезно уметь считать заранее. Эта функция учитывает и кривизну, и комиссию:
pub fn price_impact_bps(&self, side: Side, amount_in: u128) -> Result<u128, AmmError> {
let (reserve_in, reserve_out) = self.reserves_for(side);
let out = amount_out(amount_in, reserve_in, reserve_out, self.fee_bps)?;
// Идеал по споту: out = amount_in * reserve_out / reserve_in.
let ideal = amount_in.checked_mul(reserve_out).ok_or(AmmError::Overflow)? / reserve_in;
if ideal == 0 {
return Err(AmmError::ZeroAmount);
}
let lost = ideal.saturating_sub(out);
Ok(lost.checked_mul(BPS_DENOM).ok_or(AmmError::Overflow)? / ideal)
}
Важно не путать два разных явления с похожими именами. Разрыв между спотовой ценой и ценой исполнения известен заранее и считается формулой выше. А вот то, что цена успела уехать между отправкой транзакции и её включением в блок, заранее не известно, и защита от этого одна: параметр минимального выхода. Транзакция без него это открытое приглашение для сэндвича, механику которого мы разбирали в уроке про MEV.
pub fn swap(&mut self, side: Side, amount_in: u128, min_out: u128) -> Result<Swap, AmmError> {
let out = self.quote_out(side, amount_in)?;
if out < min_out {
return Err(AmmError::SlippageExceeded { got: out, min: min_out });
}
// Дальше обновление резервов.
}
Обратная задача и её округление
Иногда нужно наоборот: получить ровно 90 661 токена, вопрос сколько заплатить. Формула выводится из того же инварианта, но округление меняет направление.
/// Округление вверх (`+ 1`) обязательно: округление вниз позволило бы
/// получить выход, недоплатив один минимальный квант входа.
pub fn amount_in_for_out(
amount_out_wanted: u128,
reserve_in: u128,
reserve_out: u128,
fee_bps: Bps,
) -> Result<u128, AmmError> {
if amount_out_wanted >= reserve_out {
return Err(AmmError::InsufficientLiquidity);
}
let numerator = reserve_in
.checked_mul(amount_out_wanted)
.ok_or(AmmError::Overflow)?
.checked_mul(BPS_DENOM)
.ok_or(AmmError::Overflow)?;
let denominator = (reserve_out - amount_out_wanted)
.checked_mul(BPS_DENOM - u128::from(fee_bps))
.ok_or(AmmError::Overflow)?;
Ok(numerator / denominator + 1)
}
Правило, которое стоит запомнить на всю жизнь работы с деньгами в коде: обе стороны округления всегда в пользу общего котла. Даёшь пользователю: вниз. Берёшь с пользователя: вверх. Один квант в неправильную сторону, повторённый в цикле, это дыра.
ExactIn фиксирует вход и ищет выход; ExactOut фиксирует выход и ищет необходимый вход. Эти названия не определяют, с какого токена взимается комиссия. В Uniswap v2 обе функции учитывают комиссию со входа. У другого пула читай правила его программы и настройки: переход к ExactOut сам по себе не переносит комиссию на выход.
Доли поставщика: почему квадратный корень
Поставщик вносит оба токена и получает долю. Первому вкладчику доля считается как корень из произведения:
let minted = if self.total_shares == 0 {
let product = amount0.checked_mul(amount1).ok_or(AmmError::Overflow)?;
let root = isqrt(product);
if root <= MINIMUM_LIQUIDITY {
return Err(AmmError::InsufficientFirstDeposit);
}
self.total_shares = MINIMUM_LIQUIDITY;
root - MINIMUM_LIQUIDITY
} else {
let by0 = amount0.checked_mul(self.total_shares).ok_or(AmmError::Overflow)? / self.reserve0;
let by1 = amount1.checked_mul(self.total_shares).ok_or(AmmError::Overflow)? / self.reserve1;
by0.min(by1)
};
Почему именно корень? Потому что доля обязана быть однородной: если удвоить оба резерва, число долей тоже должно удвоиться. sqrt(x*y) этим свойством обладает, а, например, x + y нет, потому что зависит от того, в каких единицах измерены токены.
Дальше два защитных механизма.
Тысяча долей запирается навсегда. Иначе пул можно опустошить до нуля долей и заново задать курс, обобрав следующих вкладчиков на округлении. Первый вкладчик оплачивает эту защиту из своего кармана, и это осознанный размен.
Вклад считается по худшей из двух пропорций. Принесёшь перекос, лишнее просто останется в пуле подарком остальным. Правильное поведение это принести токены ровно в текущей пропорции.
премиум-урок
Продолжение в премиуме: 1 200 ₽ в месяц или 10 000 ₽ в год. Вместе с ним серверные проверки задач и школьные токены AI-коуча.
Оплата пока не подключена: премиум и курсы открываются промокодом.