Раздел 30 · Web3 на Rust

Математика AMM: пул, который сам назначает цену

senior~60 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Математика 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 X15.25423712084.745763
Два последовательных свопа по 10 X15.19543612084.804564

Здесь единый своп даёт чуть больше: комиссия первой части уже осталась в пуле и повлияла на цену второй. Условия сравнения важны: один и тот же пул, никто больше не торгует, комиссию учитываем как в Uniswap v2, округление пока игнорируем. Без комиссии два последовательных свопа дают тот же суммарный выход, что один на общий объём. Само равенство x*y=k не доказывает правило «один своп всегда выгоднее двух»; сетевые расходы и правила конкретного протокола нужно считать отдельно.

Цена исполнения и допуск проскальзывания

Пул миллион на миллион, комиссия 0.3%:

ВходВыходЦена исполненияОтклонение от спота
1 0009960.996040 bps
10 0009 8710.9871129 bps
100 00090 6610.9066933 bps
250 000199 5190.79812019 bps
500 000332 6650.66533346 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-коуча.

Оплата пока не подключена: премиум и курсы открываются промокодом.