Перейти к основному контенту

Пошаговый разбор контракта Uniswap v2

Solidity
децентрализованные приложения
Средний уровень
Ори Померанц
1 мая 2021 г.
55 минут на чтение

Введение

Uniswap v2 (opens in a new tab) может создать рынок обмена между любыми двумя токенами ERC-20. В этой статье мы рассмотрим исходный код контрактов, реализующих этот протокол, и разберемся, почему они написаны именно так.

Что делает Uniswap?

По сути, существует два типа пользователей: поставщики ликвидности и трейдеры.

Поставщики ликвидности предоставляют пулу два токена, которые можно обменивать (назовем их Token0 и Token1). Взамен они получают третий токен, который представляет собой частичное владение пулом и называется токеном ликвидности.

Трейдеры отправляют в пул один тип токена и получают другой (например, отправляют Token0 и получают Token1) из пула, предоставленного поставщиками ликвидности. Обменный курс определяется относительным количеством токенов Token0 и Token1, которые есть в пуле. Кроме того, пул взимает небольшой процент в качестве вознаграждения для пула ликвидности.

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

Нажмите здесь для более подробного описания (opens in a new tab).

Почему v2? Почему не v3?

Uniswap v3 (opens in a new tab) — это обновление, которое намного сложнее, чем v2. Проще сначала изучить v2, а затем переходить к v3.

Основные и периферийные контракты

Uniswap v2 разделен на два компонента: ядро (core) и периферию (periphery). Такое разделение позволяет основным контрактам, которые хранят активы и поэтому должны быть безопасными, оставаться более простыми и легкими для аудита. Вся дополнительная функциональность, необходимая трейдерам, может быть обеспечена периферийными контрактами.

Потоки данных и управления

Это поток данных и управления, который происходит при выполнении трех основных действий Юнисвоп:

  1. Своп между различными токенами
  2. Добавление ликвидности на рынок и получение вознаграждения в виде токенов ликвидности ERC-20 парного обменника
  3. Сжигание токенов ликвидности ERC-20 и возврат токенов ERC-20, которые парный обменник позволяет трейдерам обменивать

Своп

Это наиболее распространенный поток, используемый трейдерами:

Вызывающая сторона

  1. Предоставить периферийному аккаунту разрешение на сумму, подлежащую свопу.
  2. Вызвать одну из множества функций свопа периферийного контракта (какую именно — зависит от того, задействован ли ETH, указывает ли трейдер количество токенов для внесения или количество токенов для получения и т. д.). Каждая функция свопа принимает path — массив обменников, через которые нужно пройти.

В периферийном контракте (UniswapV2Router02.sol)

  1. Определить суммы, которые необходимо обменять на каждом обменнике по пути.
  2. Выполнить итерацию по пути. Для каждого обменника на пути отправляется входной токен, а затем вызывается функция swap обменника. В большинстве случаев адресом назначения для токенов является следующий парный обменник в пути. В последнем обменнике это адрес, предоставленный трейдером.

В основном контракте (UniswapV2Pair.sol)

  1. Убедиться, что основной контракт не обманут и может поддерживать достаточную ликвидность после свопа.
  2. Посмотреть, сколько дополнительных токенов у нас есть помимо известных резервов. Эта сумма и есть количество входных токенов, которые мы получили для обмена.
  3. Отправить выходные токены по месту назначения.
  4. Вызвать _update для обновления сумм резервов

Снова в периферийном контракте (UniswapV2Router02.sol)

  1. Выполнить необходимую очистку (например, сжечь токены WETH, чтобы получить обратно ETH для отправки трейдеру)

Добавление ликвидности

Вызывающая сторона

  1. Предоставить периферийному аккаунту разрешение на суммы, которые будут добавлены в пул ликвидности.
  2. Вызвать одну из функций addLiquidity периферийного контракта.

В периферийном контракте (UniswapV2Router02.sol)

  1. Создать новый парный обменник при необходимости
  2. Если существует парный обменник, рассчитать количество токенов для добавления. Предполагается, что это одинаковая стоимость для обоих токенов, то есть такое же соотношение новых токенов к существующим.
  3. Проверить, приемлемы ли суммы (вызывающие стороны могут указать минимальную сумму, ниже которой они предпочли бы не добавлять ликвидность)
  4. Вызвать основной контракт.

В основном контракте (UniswapV2Pair.sol)

  1. Чеканить токены ликвидности и отправить их вызывающей стороне
  2. Вызвать _update для обновления сумм резервов

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

Вызывающая сторона

  1. Предоставить периферийному аккаунту разрешение на токены ликвидности, которые будут сожжены в обмен на базовые токены.
  2. Вызвать одну из функций removeLiquidity периферийного контракта.

В периферийном контракте (UniswapV2Router02.sol)

  1. Отправить токены ликвидности на парный обменник

В основном контракте (UniswapV2Pair.sol)

  1. Отправить на адрес назначения базовые токены пропорционально сожженным токенам. Например, если в пуле 1000 токенов A, 500 токенов B и 90 токенов ликвидности, и мы получаем 9 токенов для сжигания, мы сжигаем 10% токенов ликвидности, поэтому мы отправляем пользователю обратно 100 токенов A и 50 токенов B.
  2. Сжечь токены ликвидности
  3. Вызвать _update для обновления сумм резервов

Основные контракты

Это безопасные контракты, в которых хранится ликвидность.

UniswapV2Pair.sol

Этот контракт (opens in a new tab) реализует сам пул, который обменивает токены. Это основная функциональность Юнисвоп.

Это все интерфейсы, о которых должен знать контракт, либо потому, что он их реализует (IUniswapV2Pair и UniswapV2ERC20), либо потому, что он вызывает контракты, которые их реализуют.

contract UniswapV2Pair is IUniswapV2Pair, UniswapV2ERC20 {

Этот контракт наследуется от UniswapV2ERC20, который предоставляет функции ERC-20 для токенов ликвидности.

    using SafeMath  for uint;

Библиотека SafeMath (opens in a new tab) используется для предотвращения переполнения и потери значимости. Это важно, так как в противном случае мы можем столкнуться с ситуацией, когда значение должно быть -1, но вместо этого равно 2^256-1.

    using UQ112x112 for uint224;

Многие вычисления в контракте пула требуют использования дробей. Однако дроби не поддерживаются EVM. Решение, которое нашел Юнисвоп, заключается в использовании 224-битных значений, где 112 бит отводится под целую часть, а 112 бит — под дробную. Таким образом, 1.0 представляется как 2^112, 1.5 представляется как 2^112 + 2^111 и так далее.

Более подробная информация об этой библиотеке доступна далее в документе.

Переменные

    uint public constant MINIMUM_LIQUIDITY = 10**3;

Чтобы избежать случаев деления на ноль, существует минимальное количество токенов ликвидности, которые существуют всегда (но принадлежат нулевому аккаунту). Это число — MINIMUM_LIQUIDITY, равное тысяче.

    bytes4 private constant SELECTOR = bytes4(keccak256(bytes('transfer(address,uint256)')));

Это селектор ABI для функции перевода ERC-20. Он используется для перевода токенов ERC-20 на два аккаунта токенов.

    address public factory;

Это контракт фабрики, который создал этот пул. Каждый пул представляет собой обмен между двумя токенами ERC-20, а фабрика — это центральная точка, которая связывает все эти пулы.

    address public token0;
    address public token1;

Здесь указаны адреса контрактов для двух типов токенов ERC-20, которые можно обменивать в этом пуле.

    uint112 private reserve0;           // использует один слот хранилища, доступный через getReserves
    uint112 private reserve1;           // использует один слот хранилища, доступный через getReserves

Резервы, которые есть у пула для каждого типа токенов. Мы предполагаем, что они представляют одинаковую ценность, и поэтому каждый token0 стоит reserve1/reserve0 token1.

    uint32  private blockTimestampLast; // использует один слот хранилища, доступный через getReserves

Временная метка последнего блока, в котором произошел обмен, используется для отслеживания обменных курсов с течением времени.

Одной из самых больших статей расходов газа в контрактах Эфириума является хранилище, состояние которого сохраняется от одного вызова контракта к другому. Каждая ячейка хранилища имеет длину 256 бит. Поэтому три переменные, reserve0, reserve1 и blockTimestampLast, распределены таким образом, что одно значение хранилища может включать их все (112+112+32=256).

    uint public price0CumulativeLast;
    uint public price1CumulativeLast;

Эти переменные хранят совокупные затраты для каждого токена (каждый в выражении другого). Их можно использовать для расчета среднего обменного курса за определенный период времени.

    uint public kLast; // reserve0 * reserve1, сразу после самого последнего события ликвидности

Способ, которым парный обмен определяет обменный курс между token0 и token1, заключается в поддержании постоянного произведения двух резервов во время торгов. kLast — это и есть данное значение. Оно изменяется, когда поставщик ликвидности вносит или выводит токены, и немного увеличивается из-за рыночной комиссии в 0.3%.

Вот простой пример. Обратите внимание, что для простоты в таблице указано только три цифры после запятой, и мы игнорируем торговую комиссию в 0.3%, поэтому цифры не совсем точны.

Событиеreserve0reserve1reserve0 * reserve1Средний обменный курс (token1 / token0)
Начальная настройка1,000.0001,000.0001,000,000
Трейдер A совершает своп 50 token0 на 47.619 token11,050.000952.3811,000,0000.952
Трейдер B совершает своп 10 token0 на 8.984 token11,060.000943.3961,000,0000.898
Трейдер C совершает своп 40 token0 на 34.305 token11,100.000909.0901,000,0000.858
Трейдер D совершает своп 100 token1 на 109.01 token0990.9901,009.0901,000,0000.917
Трейдер E совершает своп 10 token0 на 10.079 token11,000.990999.0101,000,0001.008

По мере того как трейдеры предоставляют больше token0, относительная стоимость token1 увеличивается, и наоборот, в зависимости от спроса и предложения.

Блокировка

    uint private unlocked = 1;

Существует класс уязвимостей безопасности, основанных на злоупотреблении повторным входом (opens in a new tab). Юнисвоп должен переводить произвольные токены ERC-20, что означает вызов контрактов ERC-20, которые могут попытаться злоупотребить вызывающим их рынком Юнисвоп. Имея переменную unlocked в составе контракта, мы можем предотвратить вызов функций во время их выполнения (в рамках одной транзакции).

    modifier lock() {

Эта функция является модификатором (opens in a new tab) — функцией, которая оборачивает обычную функцию, чтобы каким-то образом изменить ее поведение.

        require(unlocked == 1, 'UniswapV2: LOCKED');
        unlocked = 0;

Если unlocked равно единице, установите его в ноль. Если оно уже равно нулю, выполните откат вызова, чтобы он завершился ошибкой.

        _;

В модификаторе _; представляет собой исходный вызов функции (со всеми параметрами). Здесь это означает, что вызов функции происходит только в том случае, если unlocked было равно единице при вызове, и во время ее выполнения значение unlocked равно нулю.

        unlocked = 1;
    }

После возврата из основной функции снимите блокировку.

Различные функции

    function getReserves() public view returns (uint112 _reserve0, uint112 _reserve1, uint32 _blockTimestampLast) {
        _reserve0 = reserve0;
        _reserve1 = reserve1;
        _blockTimestampLast = blockTimestampLast;
    }

Эта функция предоставляет вызывающим сторонам текущее состояние обмена. Обратите внимание, что функции Solidity могут возвращать несколько значений (opens in a new tab).

    function _safeTransfer(address token, address to, uint value) private {
        (bool success, bytes memory data) = token.call(abi.encodeWithSelector(SELECTOR, to, value));

Эта внутренняя функция переводит определенное количество токенов ERC-20 с биржи кому-то другому. SELECTOR указывает, что вызываемая нами функция — это transfer(address,uint) (см. определение выше).

Чтобы избежать необходимости импортировать интерфейс для функции токена, мы «вручную» создаем вызов, используя одну из функций ABI (opens in a new tab).

        require(success && (data.length == 0 || abi.decode(data, (bool))), 'UniswapV2: TRANSFER_FAILED');
    }

Существует два способа, которыми вызов перевода ERC-20 может сообщить об ошибке:

  1. Откат. Если вызов внешнего контракта откатывается, то логическое возвращаемое значение равно false
  2. Нормальное завершение, но с сообщением об ошибке. В этом случае буфер возвращаемого значения имеет ненулевую длину, и при декодировании как логическое значение он равен false

Если происходит любое из этих условий, выполните откат.

События

    event Mint(address indexed sender, uint amount0, uint amount1);
    event Burn(address indexed sender, uint amount0, uint amount1, address indexed to);

Эти два события генерируются, когда поставщик ликвидности либо вносит ликвидность (Mint), либо выводит ее (Burn). В любом случае суммы token0 и token1, которые вносятся или выводятся, являются частью события, так же как и идентификатор аккаунта, который нас вызвал (sender). В случае вывода событие также включает цель, получившую токены (to), которая может не совпадать с отправителем.

    event Swap(
        address indexed sender,
        uint amount0In,
        uint amount1In,
        uint amount0Out,
        uint amount1Out,
        address indexed to
    );

Это событие генерируется, когда трейдер совершает своп одного токена на другой. Опять же, отправитель и получатель могут не совпадать. Каждый токен может быть либо отправлен на биржу, либо получен с нее.

    event Sync(uint112 reserve0, uint112 reserve1);

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

Функции настройки

Предполагается, что эти функции вызываются один раз при настройке нового парного обмена.

    constructor() public {
        factory = msg.sender;
    }

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

    // вызывается один раз фабрикой во время развертывания
    function initialize(address _token0, address _token1) external {
        require(msg.sender == factory, 'UniswapV2: FORBIDDEN'); // достаточная проверка
        token0 = _token0;
        token1 = _token1;
    }

Эта функция позволяет фабрике (и только фабрике) указать два токена ERC-20, которые будет обменивать эта пара.

Внутренние функции обновления

_update
    // обновляет резервы и, при первом вызове в блоке, аккумуляторы цены
    function _update(uint balance0, uint balance1, uint112 _reserve0, uint112 _reserve1) private {

Эта функция вызывается каждый раз при внесении или выводе токенов.

        require(balance0 <= uint112(-1) && balance1 <= uint112(-1), 'UniswapV2: OVERFLOW');

Если balance0 или balance1 (uint256) больше, чем uint112(-1) (=2^112-1) (поэтому происходит переполнение и возврат к 0 при преобразовании в uint112), откажитесь от продолжения _update для предотвращения переполнения. Для обычного токена, который можно разделить на 10^18 единиц, это означает, что каждый обмен ограничен примерно 5.1*10^15 каждого токена. Пока что это не было проблемой.

        uint32 blockTimestamp = uint32(block.timestamp % 2**32);
        uint32 timeElapsed = blockTimestamp - blockTimestampLast; // переполнение желательно
        if (timeElapsed > 0 && _reserve0 != 0 && _reserve1 != 0) {

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

            // * никогда не переполняется, а переполнение + желательно
            price0CumulativeLast += uint(UQ112x112.encode(_reserve1).uqdiv(_reserve0)) * timeElapsed;
            price1CumulativeLast += uint(UQ112x112.encode(_reserve0).uqdiv(_reserve1)) * timeElapsed;
        }

Каждый аккумулятор стоимости обновляется с учетом последней стоимости (резерв другого токена/резерв этого токена), умноженной на прошедшее время в секундах. Чтобы получить среднюю цену, вы считываете совокупную цену в два момента времени и делите на разницу во времени между ними. Например, предположим такую последовательность событий:

Событиеreserve0reserve1временная меткаМаржинальный обменный курс (reserve1 / reserve0)price0CumulativeLast
Начальная настройка1,000.0001,000.0005,0001.0000
Трейдер A вносит 50 token0 и получает обратно 47.619 token11,050.000952.3815,0200.90720
Трейдер B вносит 10 token0 и получает обратно 8.984 token11,060.000943.3965,0300.89020+10*0.907 = 29.07
Трейдер C вносит 40 token0 и получает обратно 34.305 token11,100.000909.0905,1000.82629.07+70*0.890 = 91.37
Трейдер D вносит 100 token1 и получает обратно 109.01 token0990.9901,009.0905,1101.01891.37+10*0.826 = 99.63
Трейдер E вносит 10 token0 и получает обратно 10.079 token11,000.990999.0105,1500.99899.63+40*1.1018 = 143.702

Допустим, мы хотим рассчитать среднюю цену Token0 между временными метками 5,030 и 5,150. Разница в значении price0Cumulative составляет 143.702-29.07=114.632. Это среднее значение за две минуты (120 секунд). Таким образом, средняя цена составляет 114.632/120 = 0.955.

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

        reserve0 = uint112(balance0);
        reserve1 = uint112(balance1);
        blockTimestampLast = blockTimestamp;
        emit Sync(reserve0, reserve1);
    }

Наконец, обновите глобальные переменные и сгенерируйте событие Sync.

_mintFee
    // если комиссия включена, чеканить ликвидность, эквивалентную 1/6 роста sqrt(k)
    function _mintFee(uint112 _reserve0, uint112 _reserve1) private returns (bool feeOn) {

В Юнисвоп 2.0 трейдеры платят комиссию в размере 0.30% за использование рынка. Большая часть этой комиссии (0.25% от сделки) всегда идет поставщикам ликвидности. Оставшиеся 0.05% могут идти либо поставщикам ликвидности, либо на адрес, указанный фабрикой в качестве комиссии протокола, которая оплачивает Юнисвоп за их усилия по разработке.

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

        address feeTo = IUniswapV2Factory(factory).feeTo();
        feeOn = feeTo != address(0);

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

        uint _kLast = kLast; // экономия газа

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

        if (feeOn) {
            if (_kLast != 0) {

Поставщики ликвидности получают свою долю просто за счет повышения стоимости их токенов ликвидности. Но комиссия протокола требует, чтобы новые токены ликвидности были отчеканены и предоставлены на адрес feeTo.

                uint rootK = Math.sqrt(uint(_reserve0).mul(_reserve1));
                uint rootKLast = Math.sqrt(_kLast);
                if (rootK > rootKLast) {

Если есть новая ликвидность, с которой можно взимать комиссию протокола. Вы можете увидеть функцию квадратного корня далее в этой статье.

                    uint numerator = totalSupply.mul(rootK.sub(rootKLast));
                    uint denominator = rootK.mul(5).add(rootKLast);
                    uint liquidity = numerator / denominator;

Этот сложный расчет комиссий объясняется в белой книге (opens in a new tab) на странице 5. Мы знаем, что с момента расчета kLast до настоящего времени ликвидность не добавлялась и не удалялась (потому что мы запускаем этот расчет каждый раз при добавлении или удалении ликвидности, до того, как она фактически изменится), поэтому любое изменение в reserve0 * reserve1 должно происходить за счет комиссий за транзакции (без них мы бы сохраняли reserve0 * reserve1 постоянным).

                    if (liquidity > 0) _mint(feeTo, liquidity);
                }
            }

Используйте функцию UniswapV2ERC20._mint для фактического создания дополнительных токенов ликвидности и назначения их на feeTo.

        } else if (_kLast != 0) {
            kLast = 0;
        }
    }

Если комиссия не установлена, задайте kLast равным нулю (если это еще не так). Когда писался этот контракт, существовала функция возврата газа (opens in a new tab), которая поощряла контракты уменьшать общий размер состояния Эфириума путем обнуления хранилища, которое им не нужно. Этот код получает этот возврат, когда это возможно.

Функции, доступные извне

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

mint
    // эта низкоуровневая функция должна вызываться из контракта, который выполняет важные проверки безопасности
    function mint(address to) external lock returns (uint liquidity) {

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

        (uint112 _reserve0, uint112 _reserve1,) = getReserves(); // экономия газа

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

        uint balance0 = IERC20(token0).balanceOf(address(this));
        uint balance1 = IERC20(token1).balanceOf(address(this));
        uint amount0 = balance0.sub(_reserve0);
        uint amount1 = balance1.sub(_reserve1);

Получите текущие балансы и посмотрите, сколько было добавлено каждого типа токенов.

        bool feeOn = _mintFee(_reserve0, _reserve1);

Рассчитайте комиссии протокола для сбора, если таковые имеются, и отчеканьте токены ликвидности соответствующим образом. Поскольку параметрами для _mintFee являются старые значения резервов, комиссия рассчитывается точно на основе только изменений пула из-за комиссий.

        uint _totalSupply = totalSupply; // экономия газа, должно быть определено здесь, так как totalSupply может обновиться в _mintFee
        if (_totalSupply == 0) {
            liquidity = Math.sqrt(amount0.mul(amount1)).sub(MINIMUM_LIQUIDITY);
           _mint(address(0), MINIMUM_LIQUIDITY); // навсегда заблокировать первые MINIMUM_LIQUIDITY токенов

Если это первый депозит, создайте токены MINIMUM_LIQUIDITY и отправьте их на нулевой адрес, чтобы заблокировать их. Они никогда не могут быть погашены, что означает, что пул никогда не будет полностью опустошен (это спасает нас от деления на ноль в некоторых местах). Значение MINIMUM_LIQUIDITY равно тысяче, что, учитывая, что большинство ERC-20 подразделяются на единицы 10^-18 токена, как ETH делится на Wei, составляет 10^-15 от стоимости одного токена. Не такая уж высокая цена.

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

Мы можем доверять этому, потому что в интересах вкладчика предоставить равную ценность, чтобы избежать потери стоимости из-за арбитража. Допустим, стоимость двух токенов идентична, но наш вкладчик внес в четыре раза больше Token1, чем Token0. Трейдер может использовать тот факт, что парный обмен считает Token0 более ценным, чтобы извлечь из этого выгоду.

Событиеreserve0reserve1reserve0 * reserve1Стоимость пула (reserve0 + reserve1)
Начальная настройка83225640
Трейдер вносит 8 токенов Token0, получает обратно 16 Token1161625632

Как видите, трейдер заработал дополнительные 8 токенов, которые происходят из-за снижения стоимости пула, нанося ущерб вкладчику, которому он принадлежит.

        } else {
            liquidity = Math.min(amount0.mul(_totalSupply) / _reserve0, amount1.mul(_totalSupply) / _reserve1);

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

Будь то первоначальный депозит или последующий, количество предоставляемых нами токенов ликвидности равно квадратному корню из изменения reserve0*reserve1, и стоимость токена ликвидности не меняется (если только мы не получим депозит, который не имеет равных значений обоих типов, и в этом случае «штраф» распределяется). Вот еще один пример с двумя токенами, имеющими одинаковую стоимость, с тремя хорошими депозитами и одним плохим (депозит только одного типа токена, поэтому он не производит никаких токенов ликвидности).

Событиеreserve0reserve1reserve0 * reserve1Стоимость пула (reserve0 + reserve1)Токены ликвидности, отчеканенные для этого депозитаВсего токенов ликвидностистоимость каждого токена ликвидности
Начальная настройка8.0008.0006416.000882.000
Депозит по четыре каждого типа12.00012.00014424.0004122.000
Депозит по два каждого типа14.00014.00019628.0002142.000
Депозит неравной стоимости18.00014.00025232.000014~2.286
После арбитража~15.874~15.874252~31.748014~2.267
        }
        require(liquidity > 0, 'UniswapV2: INSUFFICIENT_LIQUIDITY_MINTED');
        _mint(to, liquidity);

Используйте функцию UniswapV2ERC20._mint для фактического создания дополнительных токенов ликвидности и передачи их на правильный аккаунт.


        _update(balance0, balance1, _reserve0, _reserve1);
        if (feeOn) kLast = uint(reserve0).mul(reserve1); // reserve0 и reserve1 актуальны
        emit Mint(msg.sender, amount0, amount1);
    }

Обновите переменные состояния (reserve0, reserve1 и, если необходимо, kLast) и сгенерируйте соответствующее событие.

burn
    // эта низкоуровневая функция должна вызываться из контракта, который выполняет важные проверки безопасности
    function burn(address to) external lock returns (uint amount0, uint amount1) {

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

        (uint112 _reserve0, uint112 _reserve1,) = getReserves(); // экономия газа
        address _token0 = token0;                                // экономия газа
        address _token1 = token1;                                // экономия газа
        uint balance0 = IERC20(_token0).balanceOf(address(this));
        uint balance1 = IERC20(_token1).balanceOf(address(this));
        uint liquidity = balanceOf[address(this)];

Периферийный контракт перевел ликвидность для сжигания на этот контракт перед вызовом. Таким образом, мы знаем, сколько ликвидности нужно сжечь, и можем убедиться, что она будет сожжена.

        bool feeOn = _mintFee(_reserve0, _reserve1);
        uint _totalSupply = totalSupply; // экономия газа, должно быть определено здесь, так как totalSupply может обновиться в _mintFee
        amount0 = liquidity.mul(balance0) / _totalSupply; // использование балансов обеспечивает пропорциональное распределение
        amount1 = liquidity.mul(balance1) / _totalSupply; // использование балансов обеспечивает пропорциональное распределение
        require(amount0 > 0 && amount1 > 0, 'UniswapV2: INSUFFICIENT_LIQUIDITY_BURNED');

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

Остальная часть функции burn является зеркальным отражением функции mint выше.

swap
    // эта низкоуровневая функция должна вызываться из контракта, который выполняет важные проверки безопасности
    function swap(uint amount0Out, uint amount1Out, address to, bytes calldata data) external lock {

Эта функция также должна вызываться из периферийного контракта.

        require(amount0Out > 0 || amount1Out > 0, 'UniswapV2: INSUFFICIENT_OUTPUT_AMOUNT');
        (uint112 _reserve0, uint112 _reserve1,) = getReserves(); // экономия газа
        require(amount0Out < _reserve0 && amount1Out < _reserve1, 'UniswapV2: INSUFFICIENT_LIQUIDITY');

        uint balance0;
        uint balance1;
        { // область видимости для _token{0,1}, позволяет избежать ошибок слишком глубокого стека

Локальные переменные могут храниться либо в памяти, либо, если их не слишком много, непосредственно в стеке. Если мы сможем ограничить их количество так, чтобы использовать стек, мы потратим меньше газа. Для получения более подробной информации см. желтую книгу, формальные спецификации Эфириума (opens in a new tab), стр. 26, уравнение 298.

            address _token0 = token0;
            address _token1 = token1;
            require(to != _token0 && to != _token1, 'UniswapV2: INVALID_TO');
            if (amount0Out > 0) _safeTransfer(_token0, to, amount0Out); // оптимистичный перевод токенов
            if (amount1Out > 0) _safeTransfer(_token1, to, amount1Out); // оптимистичный перевод токенов

Этот перевод является оптимистичным, потому что мы переводим до того, как убедимся, что все условия выполнены. В Эфириуме это нормально, потому что если условия не будут выполнены позже в вызове, мы выполним откат из него и отменим любые созданные им изменения.

            if (data.length > 0) IUniswapV2Callee(to).uniswapV2Call(msg.sender, amount0Out, amount1Out, data);

Сообщите получателю о свопе, если это запрошено.

            balance0 = IERC20(_token0).balanceOf(address(this));
            balance1 = IERC20(_token1).balanceOf(address(this));
        }

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

        uint amount0In = balance0 > _reserve0 - amount0Out ? balance0 - (_reserve0 - amount0Out) : 0;
        uint amount1In = balance1 > _reserve1 - amount1Out ? balance1 - (_reserve1 - amount1Out) : 0;
        require(amount0In > 0 || amount1In > 0, 'UniswapV2: INSUFFICIENT_INPUT_AMOUNT');
        { // область видимости для reserve{0,1}Adjusted, позволяет избежать ошибок слишком глубокого стека
            uint balance0Adjusted = balance0.mul(1000).sub(amount0In.mul(3));
            uint balance1Adjusted = balance1.mul(1000).sub(amount1In.mul(3));
            require(balance0Adjusted.mul(balance1Adjusted) >= uint(_reserve0).mul(_reserve1).mul(1000**2), 'UniswapV2: K');

Это проверка на адекватность, чтобы убедиться, что мы не теряем от свопа. Ни при каких обстоятельствах своп не должен уменьшать reserve0*reserve1. Здесь мы также гарантируем, что при свопе отправляется комиссия в размере 0.3%; перед проверкой значения K на адекватность мы умножаем оба баланса на 1000 за вычетом сумм, умноженных на 3, это означает, что 0.3% (3/1000 = 0.003 = 0.3%) вычитается из баланса перед сравнением его значения K с текущим значением K резервов.

        }

        _update(balance0, balance1, _reserve0, _reserve1);
        emit Swap(msg.sender, amount0In, amount1In, amount0Out, amount1Out, to);
    }

Обновите reserve0 и reserve1, а при необходимости — аккумуляторы цен и временную метку, и сгенерируйте событие.

Sync или Skim

Возможно, что реальные балансы рассинхронизируются с резервами, которые, по мнению парного обмена, у него есть. Нет способа вывести токены без согласия контракта, но депозиты — это другое дело. Аккаунт может перевести токены на биржу, не вызывая ни mint, ни swap.

В этом случае есть два решения:

  • sync, обновить резервы до текущих балансов
  • skim, вывести лишнюю сумму. Обратите внимание, что любому аккаунту разрешено вызывать skim, потому что мы не знаем, кто внес токены. Эта информация генерируется в событии, но события недоступны из блокчейна.

UniswapV2Factory.sol

Этот контракт (opens in a new tab) создает парные обмены.

pragma solidity =0.5.16;

import './interfaces/IUniswapV2Factory.sol';
import './UniswapV2Pair.sol';

contract UniswapV2Factory is IUniswapV2Factory {
    address public feeTo;
    address public feeToSetter;

Эти переменные состояния необходимы для реализации комиссии протокола (см. белую книгу (opens in a new tab), стр. 5). Адрес feeTo накапливает токены ликвидности для комиссии протокола, а feeToSetter — это адрес, которому разрешено изменять feeTo на другой адрес.

    mapping(address => mapping(address => address)) public getPair;
    address[] public allPairs;

Эти переменные отслеживают пары, обмены между двумя типами токенов.

Первая, getPair, представляет собой сопоставление, которое идентифицирует контракт парного обмена на основе двух токенов ERC-20, которые он обменивает. Токены ERC-20 идентифицируются по адресам контрактов, которые их реализуют, поэтому ключи и значение — это все адреса. Чтобы получить адрес парного обмена, который позволяет вам конвертировать из tokenA в tokenB, вы используете getPair[<tokenA address>][<tokenB address>] (или наоборот).

Вторая переменная, allPairs, представляет собой массив, который включает все адреса парных обменов, созданных этой фабрикой. В Эфириуме вы не можете перебирать содержимое сопоставления или получить список всех ключей, поэтому эта переменная — единственный способ узнать, какими обменами управляет эта фабрика.

Примечание: Причина, по которой вы не можете перебирать все ключи сопоставления, заключается в том, что хранение данных контракта обходится дорого, поэтому чем меньше мы его используем, тем лучше, и чем реже мы его меняем, тем лучше. Вы можете создавать сопоставления, поддерживающие итерацию (opens in a new tab), но они требуют дополнительного хранилища для списка ключей. В большинстве приложений вам это не нужно.

    event PairCreated(address indexed token0, address indexed token1, address pair, uint);

Это событие генерируется при создании нового парного обмена. Оно включает адреса токенов, адрес парного обмена и общее количество обменов, управляемых фабрикой.

    constructor(address _feeToSetter) public {
        feeToSetter = _feeToSetter;
    }

Единственное, что делает конструктор, — это указывает feeToSetter. Фабрики запускаются без комиссии, и только feeSetter может это изменить.

    function allPairsLength() external view returns (uint) {
        return allPairs.length;
    }

Эта функция возвращает количество обменных пары.

    function createPair(address tokenA, address tokenB) external returns (address pair) {

Это основная функция фабрики — создание парного обмена между двумя токенами ERC-20. Обратите внимание, что любой может вызвать эту функцию. Вам не нужно разрешение от Юнисвоп для создания нового парного обмена.

        require(tokenA != tokenB, 'UniswapV2: IDENTICAL_ADDRESSES');
        (address token0, address token1) = tokenA < tokenB ? (tokenA, tokenB) : (tokenB, tokenA);

Мы хотим, чтобы адрес нового обмена был детерминированным, чтобы его можно было рассчитать заранее офчейн (это может быть полезно для транзакций уровня 2 (l2)). Для этого нам нужно иметь согласованный порядок адресов токенов, независимо от того, в каком порядке мы их получили, поэтому мы сортируем их здесь.

        require(token0 != address(0), 'UniswapV2: ZERO_ADDRESS');
        require(getPair[token0][token1] == address(0), 'UniswapV2: PAIR_EXISTS'); // одной проверки достаточно

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

        bytes memory bytecode = type(UniswapV2Pair).creationCode;

Для создания нового контракта нам нужен код, который его создает (как функция конструктора, так и код, который записывает в память байт-код EVM самого контракта). Обычно в Solidity мы просто используем addr = new <name of contract>(<constructor parameters>), и компилятор берет все на себя, но чтобы иметь детерминированный адрес контракта, нам нужно использовать код операции CREATE2 (opens in a new tab). Когда писался этот код, этот код операции еще не поддерживался Solidity, поэтому было необходимо получать код вручную. Это больше не проблема, потому что Solidity теперь поддерживает CREATE2 (opens in a new tab).

        bytes32 salt = keccak256(abi.encodePacked(token0, token1));
        assembly {
            pair := create2(0, add(bytecode, 32), mload(bytecode), salt)
        }

Когда код операции еще не поддерживается Solidity, мы можем вызвать его с помощью встроенного ассемблера (opens in a new tab).

        IUniswapV2Pair(pair).initialize(token0, token1);

Вызовите функцию initialize, чтобы сообщить новому обмену, какие два токена он обменивает.

        getPair[token0][token1] = pair;
        getPair[token1][token0] = pair; // заполнить отображение в обратном направлении
        allPairs.push(pair);
        emit PairCreated(token0, token1, pair, allPairs.length);
    }

Сохраните информацию о новой паре в переменных состояния и сгенерируйте событие, чтобы сообщить миру о новом парном обмене.

Эти две функции позволяют feeSetter контролировать получателя комиссии (если таковой имеется) и изменять feeSetter на новый адрес.

UniswapV2ERC20.sol

Этот контракт (opens in a new tab) реализует токен ликвидности ERC-20. Он похож на контракт ERC-20 ОпенЗеппелин, поэтому я объясню только ту часть, которая отличается — функциональность permit.

Транзакции в Эфириуме стоят эфир (ETH), что эквивалентно реальным деньгам. Если у вас есть токены ERC-20, но нет ETH, вы не можете отправлять транзакции, поэтому вы ничего не можете с ними сделать. Одно из решений, позволяющих избежать этой проблемы, — мета-транзакции (opens in a new tab). Владелец токенов подписывает транзакцию, которая позволяет кому-то другому вывести токены офчейн, и отправляет ее через Интернет получателю. Получатель, у которого есть ETH, затем отправляет разрешение от имени владельца.

    bytes32 public DOMAIN_SEPARATOR;
    // keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)");
    bytes32 public constant PERMIT_TYPEHASH = 0x6e71edae12b1b97f4d1f60370fef10105fa2faae0126114a169c64845d6126c9;

Этот хеш является идентификатором типа транзакции (opens in a new tab). Единственный, который мы здесь поддерживаем, — это Permit с этими параметрами.

    mapping(address => uint) public nonces;

Получателю нецелесообразно подделывать цифровую подпись. Однако отправить одну и ту же транзакцию дважды тривиально (это форма атаки повторного воспроизведения (opens in a new tab)). Чтобы предотвратить это, мы используем нонс (opens in a new tab). Если нонс нового Permit не на единицу больше последнего использованного, мы считаем его недействительным.

    constructor() public {
        uint chainId;
        assembly {
            chainId := chainid
        }

Это код для получения идентификатора цепи (opens in a new tab). Он использует диалект ассемблера EVM под названием Yul (opens in a new tab). Обратите внимание, что в текущей версии Yul вы должны использовать chainid(), а не chainid.

Рассчитайте разделитель домена (opens in a new tab) для EIP-712.

    function permit(address owner, address spender, uint value, uint deadline, uint8 v, bytes32 r, bytes32 s) external {

Это функция, которая реализует разрешения. Она получает в качестве параметров соответствующие поля и три скалярных значения для подписи (opens in a new tab) (v, r и s).

        require(deadline >= block.timestamp, 'UniswapV2: EXPIRED');

Не принимайте транзакции после истечения крайнего срока.

        bytes32 digest = keccak256(
            abi.encodePacked(
                '\x19\x01',
                DOMAIN_SEPARATOR,
                keccak256(abi.encode(PERMIT_TYPEHASH, owner, spender, value, nonces[owner]++, deadline))
            )
        );

abi.encodePacked(...) — это сообщение, которое мы ожидаем получить. Мы знаем, каким должен быть нонс, поэтому нам нет необходимости получать его в качестве параметра.

Алгоритм подписи Эфириума ожидает получить 256 бит для подписи, поэтому мы используем хеш-функцию keccak256.

        address recoveredAddress = ecrecover(digest, v, r, s);

Из дайджеста и подписи мы можем получить адрес, который ее подписал, используя ecrecover (opens in a new tab).

        require(recoveredAddress != address(0) && recoveredAddress == owner, 'UniswapV2: INVALID_SIGNATURE');
        _approve(owner, spender, value);
    }

Если все в порядке, рассматривайте это как одобрение ERC-20 (opens in a new tab).

Периферийные контракты

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

UniswapV2Router01.sol

Этот контракт (opens in a new tab) имеет проблемы, и его больше не следует использовать (opens in a new tab). К счастью, периферийные контракты не имеют состояния и не хранят никаких активов, поэтому легко объявить его устаревшим и предложить людям использовать вместо него замену — UniswapV2Router02.

UniswapV2Router02.sol

В большинстве случаев вы будете использовать Юнисвоп через этот контракт (opens in a new tab). Вы можете посмотреть, как его использовать, здесь (opens in a new tab).

С большинством из них мы либо сталкивались ранее, либо они довольно очевидны. Единственным исключением является IWETH.sol. Юнисвоп v2 позволяет совершать обмены для любой пары токенов ERC-20, но сам эфир (ETH) не является токеном ERC-20. Он появился до этого стандарта и переводится с помощью уникальных механизмов. Чтобы сделать возможным использование ETH в контрактах, которые применяются к токенам ERC-20, люди придумали контракт обернутого эфира (WETH) (opens in a new tab). Вы отправляете этому контракту ETH, и он чеканит вам эквивалентное количество WETH. Или вы можете сжигать WETH и получать ETH обратно.

contract UniswapV2Router02 is IUniswapV2Router02 {
    using SafeMath for uint;

    address public immutable override factory;
    address public immutable override WETH;

Маршрутизатору нужно знать, какую фабрику использовать, а для транзакций, требующих WETH, — какой контракт WETH использовать. Эти значения являются неизменяемыми (opens in a new tab), что означает, что они могут быть установлены только в конструкторе. Это дает пользователям уверенность в том, что никто не сможет изменить их, чтобы они указывали на менее честные контракты.

    modifier ensure(uint deadline) {
        require(deadline >= block.timestamp, 'UniswapV2Router: EXPIRED');
        _;
    }

Этот модификатор гарантирует, что транзакции с ограничением по времени («сделай X до времени Y, если сможешь») не произойдут после истечения их лимита времени.

    constructor(address _factory, address _WETH) public {
        factory = _factory;
        WETH = _WETH;
    }

Конструктор просто устанавливает неизменяемые переменные состояния.

    receive() external payable {
        assert(msg.sender == WETH); // принимать ETH только через fallback из контракта WETH
    }

Эта функция вызывается, когда мы выкупаем токены из контракта WETH обратно в ETH. Только используемый нами контракт WETH имеет на это разрешение.

Добавление ликвидности

Эти функции добавляют токены в парный обменник, что увеличивает пул ликвидности.


    // **** ДОБАВИТЬ ЛИКВИДНОСТЬ ****
    function _addLiquidity(

Эта функция используется для расчета количества токенов A и B, которые должны быть внесены в парный обменник.

        address tokenA,
        address tokenB,

Это адреса контрактов токенов ERC-20.

        uint amountADesired,
        uint amountBDesired,

Это суммы, которые поставщик ликвидности хочет внести. Они также являются максимальными суммами A и B для внесения.

        uint amountAMin,
        uint amountBMin

Это минимально приемлемые суммы для внесения. Если транзакция не может быть выполнена с этими суммами или больше, происходит откат. Если вам не нужна эта функция, просто укажите ноль.

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

Например, представьте случай, когда обменный курс составляет один к одному, и поставщик ликвидности указывает следующие значения:

ПараметрЗначение
amountADesired1000
amountBDesired1000
amountAMin900
amountBMin800

Пока обменный курс остается в пределах от 0.9 до 1.25, транзакция выполняется. Если обменный курс выходит за пределы этого диапазона, транзакция отменяется.

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

    ) internal virtual returns (uint amountA, uint amountB) {

Функция возвращает суммы, которые поставщик ликвидности должен внести, чтобы соотношение было равно текущему соотношению между резервами.

        // создать пару, если она еще не существует
        if (IUniswapV2Factory(factory).getPair(tokenA, tokenB) == address(0)) {
            IUniswapV2Factory(factory).createPair(tokenA, tokenB);
        }

Если обменника для этой пары токенов еще нет, создайте его.

        (uint reserveA, uint reserveB) = UniswapV2Library.getReserves(factory, tokenA, tokenB);

Получите текущие резервы в паре.

        if (reserveA == 0 && reserveB == 0) {
            (amountA, amountB) = (amountADesired, amountBDesired);

Если текущие резервы пусты, то это новый парный обменник. Суммы для внесения должны быть точно такими же, какие поставщик ликвидности хочет предоставить.

        } else {
            uint amountBOptimal = UniswapV2Library.quote(amountADesired, reserveA, reserveB);

Если нам нужно узнать, какими будут суммы, мы получаем оптимальную сумму с помощью этой функции (opens in a new tab). Мы хотим получить то же соотношение, что и у текущих резервов.

            if (amountBOptimal <= amountBDesired) {
                require(amountBOptimal >= amountBMin, 'UniswapV2Router: INSUFFICIENT_B_AMOUNT');
                (amountA, amountB) = (amountADesired, amountBOptimal);

Если amountBOptimal меньше суммы, которую поставщик ликвидности хочет внести, это означает, что токен B в настоящее время более ценен, чем думает вкладчик ликвидности, поэтому требуется меньшая сумма.

            } else {
                uint amountAOptimal = UniswapV2Library.quote(amountBDesired, reserveB, reserveA);
                assert(amountAOptimal <= amountADesired);
                require(amountAOptimal >= amountAMin, 'UniswapV2Router: INSUFFICIENT_A_AMOUNT');
                (amountA, amountB) = (amountAOptimal, amountBDesired);

Если оптимальная сумма B больше желаемой суммы B, это означает, что токены B в настоящее время менее ценны, чем думает вкладчик ликвидности, поэтому требуется большая сумма. Однако желаемая сумма является максимальной, поэтому мы не можем этого сделать. Вместо этого мы рассчитываем оптимальное количество токенов A для желаемого количества токенов B.

Собрав все это вместе, мы получаем этот график. Предположим, вы пытаетесь внести тысячу токенов A (синяя линия) и тысячу токенов B (красная линия). Ось x — это обменный курс, A/B. Если x=1, они равны по стоимости, и вы вносите по тысяче каждого. Если x=2, A в два раза дороже B (вы получаете два токена B за каждый токен A), поэтому вы вносите тысячу токенов B, но только 500 токенов A. Если x=0.5, ситуация обратная: тысяча токенов A и пятьсот токенов B.

Graph

Вы могли бы внести ликвидность непосредственно в базовый контракт (используя UniswapV2Pair::mint (opens in a new tab)), но базовый контракт проверяет только то, что его самого не обманывают, поэтому вы рискуете потерять средства, если обменный курс изменится в период между отправкой вашей транзакции и ее выполнением. Если вы используете периферийный контракт, он вычисляет сумму, которую вы должны внести, и вносит ее немедленно, поэтому обменный курс не меняется, и вы ничего не теряете.

Эта функция может быть вызвана транзакцией для внесения ликвидности. Большинство параметров такие же, как в _addLiquidity выше, за двумя исключениями:

. to — это адрес, на который чеканятся новые токены ликвидности, чтобы показать долю поставщика ликвидности в пуле . deadline — это ограничение по времени для транзакции

    ) external virtual override ensure(deadline) returns (uint amountA, uint amountB, uint liquidity) {
        (amountA, amountB) = _addLiquidity(tokenA, tokenB, amountADesired, amountBDesired, amountAMin, amountBMin);
        address pair = UniswapV2Library.pairFor(factory, tokenA, tokenB);

Мы рассчитываем суммы для фактического внесения, а затем находим адрес пула ликвидности. Чтобы сэкономить газ, мы делаем это не путем запроса к фабрике, а с помощью функции библиотеки pairFor (см. ниже в разделе библиотек)

        TransferHelper.safeTransferFrom(tokenA, msg.sender, pair, amountA);
        TransferHelper.safeTransferFrom(tokenB, msg.sender, pair, amountB);

Переведите правильные суммы токенов от пользователя в парный обменник.

        liquidity = IUniswapV2Pair(pair).mint(to);
    }

Взамен выдайте адресу to токены ликвидности за частичное владение пулом. Функция mint базового контракта видит, сколько у него дополнительных токенов (по сравнению с тем, что было в последний раз, когда ликвидность менялась), и соответственно чеканит ликвидность.

    function addLiquidityETH(
        address token,
        uint amountTokenDesired,

Когда поставщик ликвидности хочет предоставить ликвидность для парного обменника Токен/ETH, есть несколько отличий. Контракт берет на себя обертывание ETH для поставщика ликвидности. Нет необходимости указывать, сколько ETH пользователь хочет внести, потому что пользователь просто отправляет их вместе с транзакцией (сумма доступна в msg.value).

Чтобы внести ETH, контракт сначала обертывает его в WETH, а затем переводит WETH в пару. Обратите внимание, что перевод обернут в assert. Это означает, что если перевод не удастся, вызов этого контракта также завершится неудачей, и, следовательно, обертывание на самом деле не произойдет.

        liquidity = IUniswapV2Pair(pair).mint(to);
        // возместить пыль эфира, если есть
        if (msg.value > amountETH) TransferHelper.safeTransferETH(msg.sender, msg.value - amountETH);
    }

Пользователь уже отправил нам ETH, поэтому, если останется какой-либо излишек (потому что другой токен менее ценен, чем думал пользователь), нам нужно оформить возврат.

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

Эти функции удалят ликвидность и выплатят средства поставщику ликвидности.

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

        address pair = UniswapV2Library.pairFor(factory, tokenA, tokenB);
        IUniswapV2Pair(pair).transferFrom(msg.sender, pair, liquidity); // отправить ликвидность в пару
        (uint amount0, uint amount1) = IUniswapV2Pair(pair).burn(to);

Функция burn базового контракта обрабатывает возврат токенов пользователю.

        (address token0,) = UniswapV2Library.sortTokens(tokenA, tokenB);

Когда функция возвращает несколько значений, но нас интересуют только некоторые из них, именно так мы получаем только эти значения. С точки зрения газа это несколько дешевле, чем считывать значение и никогда его не использовать.

        (amountA, amountB) = tokenA == token0 ? (amount0, amount1) : (amount1, amount0);

Преобразуйте суммы из того вида, в котором их возвращает базовый контракт (сначала токен с меньшим адресом), в тот вид, который ожидает пользователь (соответствующий tokenA и tokenB).

        require(amountA >= amountAMin, 'UniswapV2Router: INSUFFICIENT_A_AMOUNT');
        require(amountB >= amountBMin, 'UniswapV2Router: INSUFFICIENT_B_AMOUNT');
    }

Нормально сначала выполнить перевод, а затем проверить его законность, потому что если это не так, мы откатим все изменения состояния.

Удаление ликвидности для ETH происходит почти так же, за исключением того, что мы получаем токены WETH, а затем выкупаем их за ETH, чтобы вернуть поставщику ликвидности.

Эти функции ретранслируют мета-транзакции, чтобы позволить пользователям без эфира выводить средства из пула, используя механизм разрешений (permit).

Эта функция может использоваться для токенов, у которых есть комиссии за перевод или хранение. Когда у токена есть такие комиссии, мы не можем полагаться на функцию removeLiquidity, чтобы узнать, сколько токенов мы получим обратно, поэтому нам нужно сначала вывести средства, а затем получить баланс.

Последняя функция объединяет комиссии за хранение с мета-транзакциями.

Торговля

    // **** СВОП ****
    // требует, чтобы начальная сумма уже была отправлена в первую пару
    function _swap(uint[] memory amounts, address[] memory path, address _to) internal virtual {

Эта функция выполняет внутреннюю обработку, которая требуется для функций, доступных трейдерам.

        for (uint i; i < path.length - 1; i++) {

На момент написания этой статьи существует 388 160 токенов ERC-20 (opens in a new tab). Если бы для каждой пары токенов существовал парный обменник, их было бы более 150 миллиардов. Во всей цепи на данный момент всего 0.1% от этого количества аккаунтов (opens in a new tab). Вместо этого функции свопа поддерживают концепцию пути. Трейдер может обменять A на B, B на C и C на D, поэтому нет необходимости в прямом парном обменнике A-D.

Цены на этих рынках, как правило, синхронизированы, потому что их рассинхронизация создает возможность для арбитража. Представьте, например, три токена: A, B и C. Существует три парных обменника, по одному для каждой пары.

  1. Исходная ситуация
  2. Трейдер продает 24.695 токенов A и получает 25.305 токенов B.
  3. Трейдер продает 24.695 токенов B за 25.305 токенов C, оставляя себе примерно 0.61 токена B в качестве прибыли.
  4. Затем трейдер продает 24.695 токенов C за 25.305 токенов A, оставляя себе примерно 0.61 токена C в качестве прибыли. У трейдера также есть 0.61 дополнительных токенов A (25.305, с которыми трейдер остается в итоге, минус первоначальные инвестиции в размере 24.695).
ШагОбменник A-BОбменник B-CОбменник A-C
1A:1000 B:1050 A/B=1.05B:1000 C:1050 B/C=1.05A:1050 C:1000 C/A=1.05
2A:1024.695 B:1024.695 A/B=1B:1000 C:1050 B/C=1.05A:1050 C:1000 C/A=1.05
3A:1024.695 B:1024.695 A/B=1B:1024.695 C:1024.695 B/C=1A:1050 C:1000 C/A=1.05
4A:1024.695 B:1024.695 A/B=1B:1024.695 C:1024.695 B/C=1A:1024.695 C:1024.695 C/A=1
            (address input, address output) = (path[i], path[i + 1]);
            (address token0,) = UniswapV2Library.sortTokens(input, output);
            uint amountOut = amounts[i + 1];

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

            (uint amount0Out, uint amount1Out) = input == token0 ? (uint(0), amountOut) : (amountOut, uint(0));

Получите ожидаемые суммы на выходе, отсортированные так, как этого ожидает парный обменник.

            address to = i < path.length - 2 ? UniswapV2Library.pairFor(factory, output, path[i + 2]) : _to;

Это последний обмен? Если да, отправьте токены, полученные за сделку, по месту назначения. Если нет, отправьте их в следующий парный обменник.


            IUniswapV2Pair(UniswapV2Library.pairFor(factory, input, output)).swap(
                amount0Out, amount1Out, to, new bytes(0)
            );
        }
    }

Фактически вызовите парный обменник для свопа токенов. Нам не нужен обратный вызов (callback), чтобы узнать об обмене, поэтому мы не отправляем никаких байтов в этом поле.

    function swapExactTokensForTokens(

Эта функция используется трейдерами напрямую для свопа одного токена на другой.

        uint amountIn,
        uint amountOutMin,
        address[] calldata path,

Этот параметр содержит адреса контрактов ERC-20. Как объяснялось выше, это массив, потому что вам может понадобиться пройти через несколько парных обменников, чтобы получить желаемый актив из того, который у вас есть.

Параметр функции в Solidity может храниться либо в memory, либо в calldata. Если функция является точкой входа в контракт, вызываемой напрямую пользователем (с помощью транзакции) или из другого контракта, то значение параметра может быть взято непосредственно из данных вызова (call data). Если функция вызывается внутренне, как _swap выше, то параметры должны храниться в memory. С точки зрения вызываемого контракта calldata доступен только для чтения.

Для скалярных типов, таких как uint или address, компилятор сам выбирает место хранения, но для массивов, которые длиннее и дороже, мы указываем тип используемого хранилища.

        address to,
        uint deadline
    ) external virtual override ensure(deadline) returns (uint[] memory amounts) {

Возвращаемые значения всегда возвращаются в памяти.

        amounts = UniswapV2Library.getAmountsOut(factory, amountIn, path);
        require(amounts[amounts.length - 1] >= amountOutMin, 'UniswapV2Router: INSUFFICIENT_OUTPUT_AMOUNT');

Рассчитайте сумму для покупки в каждом свопе. Если результат меньше минимума, который трейдер готов принять, откатите транзакцию.

        TransferHelper.safeTransferFrom(
            path[0], msg.sender, UniswapV2Library.pairFor(factory, path[0], path[1]), amounts[0]
        );
        _swap(amounts, path, to);
    }

Наконец, переведите начальный токен ERC-20 на аккаунт для первого парного обменника и вызовите _swap. Все это происходит в одной и той же транзакции, поэтому парный обменник знает, что любые неожиданные токены являются частью этого перевода.

Предыдущая функция, swapTokensForTokens, позволяет трейдеру указать точное количество входных токенов, которое он готов отдать, и минимальное количество выходных токенов, которое он готов получить взамен. Эта функция выполняет обратный своп: она позволяет трейдеру указать количество выходных токенов, которое он хочет получить, и максимальное количество входных токенов, которое он готов за них заплатить.

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

Все эти четыре варианта включают торговлю между ETH и токенами. Единственная разница заключается в том, что мы либо получаем ETH от трейдера и используем его для чеканки WETH, либо получаем WETH от последнего обменника в пути и сжигаем его, отправляя трейдеру обратно полученный ETH.

    // **** СВОП (с поддержкой токенов с комиссией за перевод) ****
    // требует, чтобы начальная сумма уже была отправлена в первую пару
    function _swapSupportingFeeOnTransferTokens(address[] memory path, address _to) internal virtual {

Это внутренняя функция для свопа токенов, у которых есть комиссии за перевод или хранение, чтобы решить (эту проблему (opens in a new tab)).

Из-за комиссий за перевод мы не можем полагаться на функцию getAmountsOut, чтобы узнать, сколько мы получаем от каждого перевода (как мы это делаем перед вызовом оригинальной _swap). Вместо этого мы должны сначала выполнить перевод, а затем посмотреть, сколько токенов мы получили обратно.

Примечание: В теории мы могли бы просто использовать эту функцию вместо _swap, но в некоторых случаях (например, если перевод в конечном итоге откатывается, потому что в конце недостаточно средств для достижения требуемого минимума) это привело бы к большим затратам газа. Токены с комиссией за перевод встречаются довольно редко, поэтому, хотя нам и нужно их учитывать, нет необходимости предполагать, что все свопы проходят хотя бы через один из них.

Это те же варианты, которые используются для обычных токенов, но вместо этого они вызывают _swapSupportingFeeOnTransferTokens.

Эти функции — просто прокси, которые вызывают функции UniswapV2Library.

UniswapV2Migrator.sol

Этот контракт использовался для миграции обменников со старой версии v1 на v2. Теперь, когда они были перенесены, он больше не актуален.

Библиотеки

Библиотека SafeMath (opens in a new tab) хорошо задокументирована, поэтому нет необходимости описывать ее здесь.

Math

Эта библиотека содержит некоторые математические функции, которые обычно не нужны в коде на Solidity, поэтому они не являются частью языка.

Начните с x в качестве оценки, которая больше квадратного корня (по этой причине нам нужно рассматривать 1-3 как особые случаи).

            while (x < z) {
                z = x;
                x = (y / x + x) / 2;

Получите более точную оценку: среднее значение предыдущей оценки и числа, квадратный корень которого мы пытаемся найти, деленное на предыдущую оценку. Повторяйте до тех пор, пока новая оценка не перестанет быть меньше существующей. Для получения более подробной информации смотрите здесь (opens in a new tab).

            }
        } else if (y != 0) {
            z = 1;

Нам никогда не понадобится квадратный корень из нуля. Квадратные корни из единицы, двойки и тройки примерно равны единице (мы используем целые числа, поэтому игнорируем дробную часть).

        }
    }
}

Дроби с фиксированной запятой (UQ112x112)

Эта библиотека работает с дробями, которые обычно не являются частью арифметики Эфириума. Она делает это путем кодирования числа x как x*2^112. Это позволяет нам использовать исходные коды операций сложения и вычитания без изменений.

Q112 — это кодировка для единицы.

    // кодировать uint112 как UQ112x112
    function encode(uint112 y) internal pure returns (uint224 z) {
        z = uint224(y) * Q112; // никогда не переполняется
    }

Поскольку y равно uint112, его максимальное значение может составлять 2^112-1. Это число все еще можно закодировать как UQ112x112.

    // разделить UQ112x112 на uint112, возвращая UQ112x112
    function uqdiv(uint224 x, uint112 y) internal pure returns (uint224 z) {
        z = x / uint224(y);
    }
}

Если мы разделим два значения UQ112x112, результат больше не будет умножаться на 2^112. Поэтому вместо этого мы берем целое число для знаменателя. Нам пришлось бы использовать аналогичный трюк для умножения, но нам не нужно выполнять умножение значений UQ112x112.

UniswapV2Library

Эта библиотека используется только периферийными контрактами

Отсортируйте два токена по адресу, чтобы мы могли получить адрес парного обменника для них. Это необходимо, потому что в противном случае у нас было бы две возможности: одна для параметров A,B и другая для параметров B,A, что привело бы к двум обменникам вместо одного.

Эта функция вычисляет адрес парного обменника для двух токенов. Этот контракт создается с использованием кода операции CREATE2 (opens in a new tab), поэтому мы можем вычислить адрес с помощью того же алгоритма, если знаем параметры, которые он использует. Это намного дешевле, чем запрашивать фабрику, и

    // получает и сортирует резервы для пары
    function getReserves(address factory, address tokenA, address tokenB) internal view returns (uint reserveA, uint reserveB) {
        (address token0,) = sortTokens(tokenA, tokenB);
        (uint reserve0, uint reserve1,) = IUniswapV2Pair(pairFor(factory, tokenA, tokenB)).getReserves();
        (reserveA, reserveB) = tokenA == token0 ? (reserve0, reserve1) : (reserve1, reserve0);
    }

Эта функция возвращает резервы двух токенов, которые есть у парного обменника. Обратите внимание, что она может получать токены в любом порядке и сортирует их для внутреннего использования.

    // учитывая некоторое количество актива и резервы пары, возвращает эквивалентное количество другого актива
    function quote(uint amountA, uint reserveA, uint reserveB) internal pure returns (uint amountB) {
        require(amountA > 0, 'UniswapV2Library: INSUFFICIENT_AMOUNT');
        require(reserveA > 0 && reserveB > 0, 'UniswapV2Library: INSUFFICIENT_LIQUIDITY');
        amountB = amountA.mul(reserveB) / reserveA;
    }

Эта функция дает вам количество токена B, которое вы получите в обмен на токен A, если комиссия не взимается. Этот расчет учитывает, что перевод изменяет обменный курс.

    // учитывая входное количество актива и резервы пары, возвращает максимальное выходное количество другого актива
    function getAmountOut(uint amountIn, uint reserveIn, uint reserveOut) internal pure returns (uint amountOut) {

Функция quote выше отлично работает, если за использование парного обменника не взимается комиссия. Однако, если комиссия за обмен составляет 0,3%, сумма, которую вы фактически получаете, будет меньше. Эта функция рассчитывает сумму после вычета комиссии за обмен.


        require(amountIn > 0, 'UniswapV2Library: INSUFFICIENT_INPUT_AMOUNT');
        require(reserveIn > 0 && reserveOut > 0, 'UniswapV2Library: INSUFFICIENT_LIQUIDITY');
        uint amountInWithFee = amountIn.mul(997);
        uint numerator = amountInWithFee.mul(reserveOut);
        uint denominator = reserveIn.mul(1000).add(amountInWithFee);
        amountOut = numerator / denominator;
    }

Solidity не поддерживает дроби изначально, поэтому мы не можем просто умножить сумму на 0,997. Вместо этого мы умножаем числитель на 997, а знаменатель на 1000, достигая того же эффекта.

    // учитывая выходное количество актива и резервы пары, возвращает необходимое входное количество другого актива
    function getAmountIn(uint amountOut, uint reserveIn, uint reserveOut) internal pure returns (uint amountIn) {
        require(amountOut > 0, 'UniswapV2Library: INSUFFICIENT_OUTPUT_AMOUNT');
        require(reserveIn > 0 && reserveOut > 0, 'UniswapV2Library: INSUFFICIENT_LIQUIDITY');
        uint numerator = reserveIn.mul(amountOut).mul(1000);
        uint denominator = reserveOut.sub(amountOut).mul(997);
        amountIn = (numerator / denominator).add(1);
    }

Эта функция делает примерно то же самое, но она получает выходную сумму и предоставляет входную.

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

Transfer Helper

Эта библиотека (opens in a new tab) добавляет проверки успешности для переводов ERC-20 и Эфириума, чтобы одинаково обрабатывать откат и возврат значения false.

Мы можем вызвать другой контракт одним из двух способов:

        require(
            success && (data.length == 0 || abi.decode(data, (bool))),
            'TransferHelper::safeApprove: approve failed'
        );
    }

Ради обратной совместимости с токенами, которые были созданы до стандарта ERC-20, вызов ERC-20 может завершиться неудачей либо путем отката (в этом случае success равно false), либо будучи успешным и возвращая значение false (в этом случае есть выходные данные, и если вы декодируете их как логическое значение, вы получите false).

Эта функция реализует функциональность перевода ERC-20 (opens in a new tab), которая позволяет аккаунту расходовать разрешение, предоставленное другим аккаунтом.

Эта функция реализует функциональность transferFrom ERC-20 (opens in a new tab), которая позволяет аккаунту расходовать разрешение, предоставленное другим аккаунтом.


    function safeTransferETH(address to, uint256 value) internal {
        (bool success, ) = to.call{value: value}(new bytes(0));
        require(success, 'TransferHelper::safeTransferETH: ETH transfer failed');
    }
}

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

Заключение

Это длинная статья объемом около 50 страниц. Если вы дочитали до этого места, поздравляем! Надеемся, теперь вы понимаете, что нужно учитывать при написании реального приложения (в отличие от коротких примеров программ), и лучше подготовлены к написанию контрактов для собственных сценариев использования.

А теперь идите, напишите что-нибудь полезное и удивите нас.

Здесь вы можете найти больше моих работ (opens in a new tab).