В прошлой статье мы уже подробно разбирали принцип работы Uniswap v3.
В этой статье заострим внимание на самых неочевидных моментах логики этого автоматического маркет мейкера
Структура этой статьи
Вспоминаем основы математики Uniswap v3
Как работают кросс-тик свопы (свопы, при которых изменяются ценовые "тики")
Как использовать совокупную ликвидность от разных LP-позиций лучше чем за O(n)? И как это связано с тиками?
Допустим, у пуле открыто 1 миллиард разных Uniswap V3 LP-позиций. Как будет выглядеть суммарный график y(x) по всему ценовому пространству?
Доказать, что суммарный график y(x) будет непрерывно-дифференцируемой функцией
Основы математики
Первый шаг: whitepaper Uinswap V3 и ключевые вещи. Обменный курс между token0 и token1 определяется как цена. Весь ценовой диапазон разделён на так называемые тики (ticks), где каждый тик представляет собой целую степень числа 1,0001. Цена для -го тика определяется следующим образом:

Таким образом, каждый тик i (со знаком) соответствует изменению цены на 0,01% (1 базисный пункт) относительно тика с = 0. Цена может двигаться выше и ниже «1,0» небольшими шагами по 0,01% в пределах целочисленного пространства (-∞, +∞).
Небольшое отступление
В своём телеграм канале делюсь ещё больше полезным контентом по сфере децентрализованных финансов: https://t.me/kirrya_achieves
Ликвидность пула связана с параметром sqrtPrice, который определяется следующим образом:

где и
— виртуальные (не реальные, это важно) резервы token0 и token1 соответственно.
Пул Uniswap V3 состоит из множества «маленьких» swap-пулов, каждый из которых соответствует отдельному тику. Каждый такой «маленький» пул ведёт себя как Uniswap V2 с постоянным произведением резервов (x \cdot y = const), но с важным отличием — запасы токенов в конкретном тике ограничены. У каждого тика есть максимальная и минимальная цена обмена, а также ограниченное количество token0 и token1.
Когда запасы token0 и token1 в тике полностью исчерпаны, ликвидность этого тика падает до нуля. Таким образом, в Uniswap V3 уравнение постоянного произведения немного отличается и выглядит следующим образом:

(гдеa и
b - нижняя и верхняя границы ценового диапазона для данного тика.
График этой функции представляет собой гиперболу, однако, в отличие от Uniswap V2, ликвидность пула может закончиться (когда доходим до оси абсцисс или ординат)

Если резервы токенов в текущем тике закончились, а обмен еще не завершен (не достигнуто желаемое количество токенов или цена), Uniswap V3 переходит к следующему активному тику и выполняет следующий «прыжок» (hop), продолжая своп. Этот процесс повторяется до тех пор, пока не будет получено нужное количество токенов или не будет достигнута целевая цена обмена.
Кросс-тик свопы
Рассмотрим кросс-тик своп на упрощенной схеме:

Начальное состояние пула: текущая цена (тик) определяет, где начинается своп (зелёный цвет). Пользователь передаёт token0 (синий) и получает token1 (жёлтый).
Если объем свопа невелик и цена не выходит за пределы или
(нижней и верхней границы текущего тика), то обмен происходит по формуле постоянного произведения (виртуальных балансов)
Если же объем свопа превышает текущий тик:
При обмене token0 → token1 (движение вправо на схеме) резервы token1 (желтый) в текущем тике полностью исчерпываются, оставляя только token0 (синий).
Затем текущий тик переходит на следующий тик, где есть резервы token1 (тик "i+n" на схеме).
Своп продолжается до тех пор, пока не будет достигнут нужный объем токенов или цена.
В результате все тики слева от текущего тика содержат только token0, а тики справа — только token1.
Свопы в обратном направлении (token1 → token0) работают аналогично, но в зеркальном порядке: токены перемещаются влево, и текущий тик обновляется при исчерпании token0.
Параметр n на схеме (шага между тиками) связан с параметром tickSpacing пула и будет рассмотрен позже.
Прямое внедрение этой логики с индивидуальными резервами для каждого тика, обновлениями при каждом свопе и изменении ликвидности, а также расчётами комиссий в стиле Uniswap V2 привело бы к значительному количеству переменных и высоким затратам на газ.
Поэтому необходим математический подход, позволяющий удешевить кросс-тик-свопы, расчёты ликвидности и комиссий. Наша цель — добиться практически постоянных затрат на газ и минимального использования хранилища. Эти требования стали основной причиной использования ликвидности (и
) и sqrtPrice (
и
) вместо прямых объёмов токенов, как это было в Uniswap V2.
Первый важный аспект использования единого значения ликвидности () и квадратного корня цены (
) заключается в том, что при свопах и добавлении/удалении ликвидности изменяется только одно из этих значений.
изменяется при свопе внутри тика, а
изменяется при пересечении тика или изменении ликвидности (mint/burn).
Кроме того, использование вместо
позволяет избежать необходимости вычисления квадратного корня
sqrt()
, который не имеет постоянной стоимости газа. В Uniswap V2 sqrt()
вычисляется методом Вавилона, что делает его затраты на газ переменными. В Uniswap V3 вычисления sqrt()
вообще отсутствуют — вместо этого используются предварительно рассчитанные значения в TickMath.sol (здесь).
Использование √P и L позволяет легко вычислить количество получаемых токенов при свопе и изменение √P по следующим простым формулам:

и

Эти вычисления реализованы в функциях getAmount0Delta() и getAmount1Delta().
Следующий важный аспект реализации Uniswap V3 — это tickSpacing. Этот параметр определяет «шаг» между тиками, в которых допускается наличие ликвидности. Например, если tickSpacing установлен на 60, то ликвидность может существовать только в тиках 0, 60, -60, 120, -120 и так далее.
tickSpacing дает создателям пулов возможность выбора: они могут сделать шаг между тиками меньше, получив более «плотный» пул (более детализированные ценовые шаги, но более дорогие кросс-тик свопы, так как своп проходит через большее количество тиков), или сделать пул более «разреженным», где ценовые шаги будут крупнее (меньше пересечений тиков — меньше затрат на газ, но и меньшая точность для поставщиков ликвидности).
Как использовать совокупную ликвидность от разных LP-позиций? И как это связано с тиками?
В Uniswap V3 ликвидность предоставляется путём создания «позиций», принадлежащих пользователям. Этот механизм отличается от Uniswap V2, где владение ликвидностью реализовано через токены LP стандарта ERC-20. В Uniswap V3 «позиция» представляет собой объект, принадлежащий пользователю, содержащий определённое количество ликвидности и диапазон тиков (от tickLower до tickUpper). Как видно, операции mint и burn очень похожи: при добавлении ликвидности (mint) она прибавляется к позиции, а при удалении (burn) — вычитается.
Можно задаться вопросом – а всё же зачем нужны тики? Почему мы без них не можем?
С одной стороны, мы хотим чтобы пользователи могли предоставлять ликвидность в произвольном диапазоне цен. С другой стороны, мы хотим чтобы каждый своп стоит константное количество газа (другими словами, сложность свопа чтобы была O(1)).
С другой стороны, если допустить произвольные интервалы цен для предоставления ликвидности, значит количество точек где изменяется значения суммарной ликвидности, будет расти как O(n), n - количество ЛП позиций.
Поэтому, если мы ограничим количество тиков (в uniswap v3 их примерно 1.4 млн), сложность свопа не будет зависеть от кол-ва ЛП позиций, а ограничена сверху количеством тиков, которых константное число.
Теперь рассмотрим процесс создания и удаления позиции в функции _updatePosition().
Она принимает tickLower
, tickUpper
, количество ликвидности, которое нужно добавить или убрать (liquidityDelta
), а также текущий тик пула.
Эффективность математической модели Uniswap V3 хорошо заметна в этой части _updatePosition()
. Можно предположить, что поставщики ликвидности (LP) должны распределять ликвидность по ВСЕМ тикам в диапазоне от tickLower
до tickUpper
(с учётом tickSpacing
), но в коде мы видим только два обновления:
flippedLower = ticks.update(
tickLower,
...
);
flippedUpper = ticks.update(
tickUpper,
...
);
То есть обновляются только нижний и верхний тики. Это эффективно, но как же тогда собираются комиссии со ВСЕХ тиков внутри этого диапазона без хранения значений ликвидности в каждом тике? Сначала разберемся с комиссиями.
Как начислять комиссии за каждую сделку одновременно для всех LP-позиций?
Ключевая концепция системы комиссий Uniswap V3 заключается в отслеживании «внешнего» (outside) объема комиссий «выше» и «ниже» тика и сохранении этих значений внутри каждого тика. Эти комиссии рассчитываются по следующим формулам из вайтпеппера:

(где - значение feeGrowthOutside, хранящееся по
-му тику,
- общая сумма накопленных комиссий в пуле)
Важно: считаем комиссии в размере на единицу ликвидности
При этом в реализации используется только ОДНО значение для отслеживания этих объемов комиссий — feeGrowthOutside
, хранящееся внутри тика (отдельно для token0 и token1).
Применяя вышеуказанные формулы, значение feeGrowthOutside
для заданного тика () равно:
Кумулятивной сумме комиссий НИЖЕ тика
, если он находится ЛЕВЕЕ
(текущего тика цены)
Кумулятивной сумме комиссий ВЫШЕ тика
, если он находится СПРАВА
Где – текущий тик цены,
– рассматриваемый тик.
Примеры:
Еслимы движемся вправо, то
feeGrowthOutside
хранит «левую» часть комиссий:

Если мы пересекли текущий ценовой тик, и теперь
feeGrowthOutside
хранит «правую» часть комиссий:

Поскольку тики пересекаются последовательно, при каждом пересечении feeGrowthOutside
обновляется, отражая либо «левую», либо «правую» часть глобальных комиссий.
Благодаря такому подходу расчёт комиссий, относящихся к заданному диапазону ( — нижний тик,
— верхний тик), становится простым:

Мы просто вычитаем «комиссии ниже нижнего тика» и «комиссии выше верхнего тика» из общего пула комиссий. Кроме того, важно помнить, что feesGrowth
измеряется в «токенах на единицу ликвидности», поэтому расчёт конечного объёма комиссий также упрощается.
Чтобы определить комиссии, относящиеся к позиции пользователя, сначала удаляется ликвидность (remove liquidity), а затем выполняются вычисления в ticks.getFeeGrowthInside()
, используя описанные выше формулы:
if (tickCurrent >= tickLower) {
feeGrowthBelow0X128 = lower.feeGrowthOutside0X128;
feeGrowthBelow1X128 = lower.feeGrowthOutside1X128;
} else {
feeGrowthBelow0X128 = feeGrowthGlobal0X128 - lower.feeGrowthOutside0X128;
feeGrowthBelow1X128 = feeGrowthGlobal1X128 - lower.feeGrowthOutside1X128;
}
Таким образом, для расчёта собранных комиссий внутри диапазона тиков достаточно считать состояние только двух тиков и глобальное состояние пула, что делает процесс чрезвычайно эффективным.
Возможно, вы заметили, что нигде в коде не накладываются ограничения на tickSpacing, и кажется, что поставщики ликвидности могут устанавливать tickLower и tickUpper на любых значениях. Однако эта проверка выполняется внутри функции flipTick(), которая включает и отключает тики.
Допустим, у пуле открыт 1 миллиард разных Uniswap V3 LP-позиций. Как будет выглядеть суммарный график y(x) по всему ценовому пространству?
Доказать, что суммарный график будет непрерывно-дифференцируемой функцией
Вспомним, что на каждом ценовом тике у нас просто гипербола, смещенная влево и вниз. Причем коэффициенты смещения зависят от значения самой ликвидности L.
Вспомним также, что цена в Uniswap v3 гиперболе – это просто производная (касательная).
Как определяется склеивание двух гипербол на соседних тиках? Оно определяется так, что нижняя цена верхней гиперболы совпадает с верхней ценой нижней гиперболы. А это значит что цены справа и слева совпадают. Цена - это производная к нашему суммарному графику, и пределы справа и слева совпадают
Таким образом, несмотря на всю сложность конструкции Uniswap v3 LP позиций, суммарный график будет гладкой (непрерывно-дифференцируемой) кривой.
Надеюсь, что статья для вас была интересной и полезной. Буду рад прочитать ваши комментарии
В своём телеграм канале делюсь ещё больше полезным контентом по сфере децентрализованных финансов:
References:
https://mixbytes.io/blog/uniswap-v3-ticks-dive-into-concentrated-liquidity