Введение

Это вторая часть серии статей о программировании DeFi смарт-контрактов. В предыдущей части мы впервые соприкоснулись с Uniswap, его основной механикой и начали создавать смарт-контракт Биржи, который может принимать ликвидность от пользователей, рассчитывать суммы вывода и выполнять обмены.

Сегодня мы собираемся закончить реализацию Uniswap V1. Хотя это не будет полная копия Uniswap V1, но она будет иметь все основные функции.

Эта часть наполнена новым кодом, поэтому давайте перейдем непосредственно к нему. Чтобы увидеть полный код проекта нажмите здесь.

Увеличение ликвидности

В предыдущей части мы говорили о том, что наша реализация addLiquidity не совершена. На это была причина, и сегодня мы завершим разработку этой функции.

Пока что функция выглядит так:

function addLiquidity(uint256 _tokenAmount) public payable {
  IERC20 token = IERC20(tokenAddress);
  token.transferFrom(msg.sender, address(this), _tokenAmount);
}

Какая в ней проблема? Функция позволяет в любой момент добавить произвольное количество ликвидности.

Как вы помните, обменный курс рассчитывается как соотношение резервов:

Где Px и Py - цены eth и токена; x и y - запасы eth и токена.

Мы также узнали, что обмен токенов изменяет резервы нелинейным образом, что влияет на цены, и что арбитражники получают прибыль, балансируя цены таким образом, чтобы они совпадали с ценами на крупных централизованных биржах.

Проблема нашей реализации заключается в том, что наш код позволяет существенно изменять цены в любой момент времени. Или, другими словами, текущая реализация функции не обеспечивает соблюдение соотношения текущих резервов и новой ликвидности. Это является проблемой, потому что это позволяет манипулировать ценами, а мы хотим, чтобы цены на децентрализованных биржах были максимально близки к ценам на централизованных биржах. И мы хотим, чтобы наши смарт-контракты Бирж выступали в качестве ценовых оракулов.

Таким образом, мы должны гарантировать, что добавляемая ликвидность размещается в той же пропорциональном соотношении eth-токен, которая уже установилась в пуле. В то же время мы хотим, чтобы на этапе создания Биржи ликвидность поступала в произвольной пропорции eth-токен, когда изначальные резервы пусты, т.е. когда пул еще не инициализирован. Это важно, так как именно в этот момент цена eth-токен устанавливается в первый раз.

Теперь addLiquidity будет иметь две ветви:

  1. Если это новая Биржа (без ликвидности, пул пуст), позволить завести произвольное количество ликвидности.

  2. В противном случае, соблюсти установленную пропорцию резервов, когда есть ликвидность.

Первая ветвь остается без изменений:

if (getReserve() == 0) {
    IERC20 token = IERC20(tokenAddress);
    token.transferFrom(msg.sender, address(this), _tokenAmount);

Вторая ветвь это уже новый код:

} else {
    uint256 ethReserve = address(this).balance - msg.value;
    uint256 tokenReserve = getReserve();
    uint256 tokenAmount = (msg.value * tokenReserve) / ethReserve;
    require(_tokenAmount >= tokenAmount, "insufficient token amount");
    IERC20 token = IERC20(tokenAddress);
    token.transferFrom(msg.sender, address(this), tokenAmount);
}

Единственное отличие заключается в том, что мы депонируем не все токены, предоставленные пользователем, а только сумму, рассчитанную на основе текущего соотношения резервов. Чтобы получить сумму, мы умножаем соотношение (tokenReserve / ethReserve) на количество депонированных eth. Если пользователь вложил меньше этой суммы, будет выдана ошибка.

Это позволит сохранить цену при добавлении ликвидности в пул.

LP-токены

Мы ещё не обсуждали эту концепцию, но она является важной частью дизайна Uniswap.

Нам необходимо иметь способ вознаграждения поставщиков ликвидности за их вклад. Если у них не будет мотивации, то они не будут предоставлять ликвидность, потому что никто не станет вкладывать свои eth\токены в сторонний смарт-контракт просто так. Более того, вознаграждение поставщикам ликвидности не должно выплачиваться нами (разработчиками), потому что для этого нам (разработчикам) пришлось бы искать инвестиции или выпускать свой инфляционный токен.

Единственным хорошим решением является взимание небольшой комиссии с каждого обмена токенов и распределение накопленной комиссии между поставщиками ликвидности. Это также кажется вполне обоснованным: пользователи (трейдеры) платят за услуги (ликвидность), предоставляемые другими людьми.

Чтобы вознаграждение было справедливым, мы должны вознаграждать поставщиков ликвидности пропорционально их вкладу, т.е. количеству ликвидности, которую они предоставляют. Если кто-то предоставил 50% ликвидности пула, он должен получить 50% накопленных средств. В этом есть смысл, верно?

Сейчас эта задача кажется довольно сложной. Однако есть элегантное решение: токены поставщиков ликвидности или LP-токены.

LP-токены - это, по сути, токены ERC20, автоматически выпущенные и переданные поставщикам ликвидности в обмен на их вклад в ликвидность. По сути, LP-токены - это акции:

  1. Вы получаете LP-токены в обмен на вашу ликвидность, которую вы предоставили в пул.

  2. Количество получаемых вами LP-токенов пропорционально доле вашей ликвидности в резервах пула.

  3. Комиссионные распределяются пропорционально количеству принадлежащих вам LP-токенов.

  4. LP-токены можно обменять обратно на ликвидность и получить накопленные комиссии.

Хорошо, как мы будем рассчитывать количество выпущенных LP-токенов в зависимости от объема предоставленной ликвидности? Это не так очевидно, потому что есть некоторые требования, которым мы должны соответствовать:

  1. Каждая выпущенная акция должна быть всегда правильной. Если кто-то после меня добавляет или удаляет из пула ликвидность, моя доля должна оставаться соответствующей моему вкладу в общем объеме ликвидности.

  2. Операции записи (например, сохранение новых данных или обновление существующих данных в смарт-контракте) в Ethereum очень дороги. Поэтому мы хотим снизить затраты на обслуживание LP-токенов (т. е. мы не хотим запускать функции, которое регулярно пересчитывает и обновляют пропорциональное соотношение долей).

Представьте, что мы выпускаем много токенов (скажем, 1 миллиард) и распределяем их между всеми поставщиками ликвидности. Если мы всегда распределяем все токены (первый поставщик ликвидности получает 1 млрд, второй - его долю и т.д.), то мы вынуждены пересчитывать в последствии выпущенные доли, что дорого. Если мы изначально распределим только часть токенов, то рискуем попасть в лимит предложения, что в конечном итоге вынудит перераспределить имеющиеся доли.

Единственным хорошим решением является полное отсутствие лимитов по запасам и выпуску новых токенов при добавлении новой ликвидности. Это позволяет бесконечно наращивать LP-токены, и, если мы используем правильную формулу, все выпущенные LP-токены останутся в правильном соотношении к общему объему ликвидности (будут пропорционально увеличиваться) при добавлении или удалении ликвидности. К счастью, инфляция не снижает стоимость LP-токенов, потому что они всегда подкреплены некоторым количеством ликвидности, которое не зависит от количества выпущенных LP-токенов.

Теперь последняя деталь в этой головоломке: как подсчитать количество LP-токенов, которые нужно выпустить при внесении ликвидности?

В смарт-контракте Биржи сохраняются резервы eth и токенов. Но как мы будем рассчитывать кол-во LP-токенов? На основе общего резерва? Или только одного из них (eth, токен)? Uniswap V1 рассчитывает количество LP-токенов пропорционально резерву eth, но Uniswap V2 может проводить обмен только между токенами (не между eth и токеном), поэтому неясно, какой расчёт выбирать. Давайте придерживаться того, что делает Uniswap V1, а позже мы посмотрим, как решить эту проблему, когда есть два токена ERC20.

Это уравнение показывает, как рассчитывается количество новых LP-токенов в зависимости от количества вложенных eth:

Формула чеканки LP-токенов
Формула чеканки LP-токенов

Каждый поставщик ликвидности отчеканивает себе LP-токены пропорционально доле размещённых eth в общем резерве eth. Это непросто, попробуйте подставить разные числа в это уравнение и посмотрите, как изменится общая сумма. Например, какими будут amountMinted и totalAmount, если кто-то депонирует определенное количество eth в etherReserve? Остаются ли выпущенные до этого акции правильными (правильное соотношение к обновлённому размеру ликвидности)?

Перейдём к коду.

Прежде чем модифицировать addLiquidity, нам нужно сделать наш смарт-контракт Exchange контрактом ERC20 и изменить его конструктор:

contract Exchange is ERC20 {
    address public tokenAddress;
    constructor(address _token) ERC20("Zuniswap-V1", "ZUNI-V1") {
        require(_token != address(0), "invalid token address");
        tokenAddress = _token;
    }
}

Наши LP-токены будут иметь имя и символ. Не стесняйтесь взять этот код и улучшить его.

Теперь обновим addLiquidity: при добавлении начальной ликвидности количество выпущенных LP-токенов равно количеству внесенных eth.

function addLiquidity (uint256 _tokenAmount)
    public
    payable
    returns (uint256)
{
    if (getReserve() == 0) {
        ...
        uint256 liquidity = address(this).balance;
        _mint(msg.sender, liquidity);
        return liquidity;

Дополнительная ликвидность выпускает LP-токены пропорционально количеству вложенных eth:

 } else {
        ...
        uint256 liquidity = (totalSupply() * msg.value) / ethReserve;
        _mint(msg.sender, liquidity);
        return liquidity;
    }
}

Всего несколько строк, и у нас теперь есть LP-токены!

Сборы-поборы

Теперь мы готовы к сбору комиссионных по производимым в пуле обменам. Перед этим нам нужно ответить на несколько вопросов:

  1. Хотим ли мы брать комиссионные в eth или токенах? Хотим ли мы выплачивать вознаграждение поставщикам ликвидности в eth или токенах?

  2. Как собрать небольшую фиксированную плату с каждого обмена?

  3. Как распределить накопленные комиссии между поставщиками ликвидности пропорционально их вкладу?

Опять же, это может показаться сложной задачей, но у нас уже есть все для ее решения.

Давайте подумаем над последними двумя вопросами. Мы можем ввести дополнительный платеж, который отправляется вместе со сделкой обмена. Такие платежи накапливаются в фонде, из которого любой поставщик ликвидности может изъять сумму, пропорциональную своей доле. Это звучит как разумная идея, и, как ни удивительно, она уже почти реализована:

  1. Трейдеры уже отправляют eth/токены в смарт-контракт Биржи. Вместо того чтобы запрашивать комиссию, мы можем просто вычесть ее из eth/токенов, которые отправляются на смарт-контракт.

  2. У нас уже есть фонд - это резервы Биржи! Резервы могут быть использованы для накопленных сборов. Это также означает, что резервы будут расти со временем, так что формула постоянного соотношения торгуемых пар не такая уж и постоянная! Однако это не делает ее недействительной: комиссия мала по сравнению с резервами, и нет способа манипулировать ею, чтобы попытаться существенно изменить резервы.

  3. И теперь у нас есть ответ на первый вопрос: комиссии выплачиваются в валюте торгуемого актива. Поставщики ликвидности получают сбалансированное количество eth и токенов плюс долю накопленных комиссий, пропорциональную доле их LP-токенов.

Вот и все! Переходим к коду.

Uniswap берет 0,03% комиссии с каждого обмена. Мы возьмем 1%, просто чтобы было легче увидеть разницу в тестах. Добавить комиссию в смарт-контракт так же просто, как добавить пару множителей в функцию getAmount:

function getAmount(
        uint256 inputAmount,
        uint256 inputReserve,
        uint256 outputReserve
    ) private pure returns (uint256) {
        require(inputReserve > 0 && outputReserve > 0, "invalid reserves");
        uint256 inputAmountWithFee = inputAmount * 99;
        uint256 numerator = inputAmountWithFee * outputReserve;
        uint256 denominator = (inputReserve * 100) + inputAmountWithFee;
        return numerator / denominator;
    }

Поскольку Solidity не поддерживает деление с плавающей запятой, нам придется прибегнуть к хитрости: числитель и знаменатель умножаются на степень 10, а сбор вычитается из множителя в числителе. Обычно мы вычисляем это так:

В Solidity мы должны делать это именно так:

Но это все равно одно и то же.

Удаление ликвидности

Наконец, последняя функция в нашем списке: removeLiquidity.

Для удаления ликвидности мы снова можем использовать LP-токены: нам не нужно помнить суммы, депонированные каждым поставщиком ликвидности, и мы можем рассчитать количество удаленной ликвидности на основе доли LP-токенов.

function removeLiquidity(uint256 _amount)
        public
        returns (uint256, uint256)
    {
        require(_amount > 0, "invalid amount");
        uint256 ethAmount = (address(this).balance * _amount) / totalSupply();
        uint256 tokenAmount = (getReserve() * _amount) / totalSupply();
        _burn(msg.sender, _amount);
        payable(msg.sender).transfer(ethAmount);
        IERC20(tokenAddress).transfer(msg.sender, tokenAmount);
        return (ethAmount, tokenAmount);
    }

Когда ликвидность изымается, она возвращается как в eth, так и в токенах, и их количество, конечно же, уравновешивается. Именно этот момент и приводит к непостоянным потерям: соотношение резервов меняется со временем вслед за изменениями их цен в российских рублях. Когда ликвидность удаляется, баланс может отличаться от того, каким он был, когда ликвидность была внесена. Это означает, что вы получите разное количество eth и токенов, а их общая цена может быть ниже, чем если бы вы просто держали их в кошельке.

Для расчета сумм мы умножаем резервы на долю LP-токенов:

Обратите внимание, что LP-токены сгорают каждый раз, когда ликвидность снимается. LP-токены обеспечены только депонированной ликвидностью.

LP-вознаграждения и непостоянные потери

Давайте напишем тест, который воспроизводит полный цикл добавления ликвидности, обмена токенов, накопления комиссии и удаления ликвидности:

  1. Сначала поставщик ликвидности вносит 100 eth и 200 токенов. Таким образом, 1 токен равен 0,5 eth, а 1 eth равен 2 токенам.
    exchange.addLiquidity(toWei(200), { value: toWei(100) });

  2. Пользователь обменивает 10 eth и ожидает получить не менее 18 токенов. Фактически он получил 18,0164 токенов. Сюда входит проскальзывание (торгуемые суммы относительно велики) и комиссия в размере 1%.
    exchange.connect(user).ethToTokenSwap(toWei(18), { value: toWei(10) });

  3. Поставщик ликвидности затем удаляет свою ликвидность:
    exchange.removeLiquidity(toWei(100));

  4. Поставщик ликвидности получил 109,9 eth (с учетом комиссии за транзакцию) и 181,9836 токенов. Как видите, эти цифры отличаются от тех, что были внесены: мы получили 10 eth, которыми торговал пользователь, но в обмен пришлось отдать 18,0164 токенов. Однако эта сумма включает в себя 1% комиссию, которую пользователь заплатил нам. Поскольку поставщик ликвидности предоставил всю ликвидность, он получил все комиссионные.

Заключение

Надеюсь, LP-токены больше не являются для вас загадкой.

Однако мы еще не закончили: Смарт-контракт Биржи завершен, но нам также необходимо реализовать смарт-контракт Фабрики (Factory), который служит в качестве реестра Бирж и моста, соединяющего несколько Бирж и делающего возможным обмен токенов на токены. Мы реализуем его в следующей части!

Серия статей

  1. Программирование DeFi: Uniswap. Часть 1

  2. Программирование DeFi: Uniswap. Часть 2

  3. Программирование DeFi: Uniswap. Часть 3

Полезные ссылки

  1. Полный код этой части

  2. Техническое описание Uniswap V1

Комментарии (0)