Pull to refresh

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

Reading time14 min
Views26K
Original author: Ivan Kuznetsov

Введение

Лучший способ научиться чему-то - научить других. Второй лучший способ научиться чему-то - сделать это самому. Я решил объединить эти два способа и научить себя и вас программировать DeFi сервисы на Ethereum (и любых других блокчейнах, основанных на EVM - Ethereum Virtual Machine).

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

Однако мы будем работать только над смарт-контрактами: создание front-end для смарт-контрактов - тоже большая и интересная задача, но она выходит за рамки этой статьи.

Давайте начнем наше путешествие с Uniswap. Полный исходный код можно найти здесь.

Различные версии Uniswap

По состоянию на июнь 2021 года было запущено три версии Uniswap.

Первая версия (V1) была запущена в ноябре 2018 года и допускала обмен только между eth и токенами. А также были возможны обмены токенов на токены.

Вторая версия (V2) была запущена в марте 2020 года и представляла собой улучшение V1, позволяя осуществлять прямой обмен между любыми токенами ERC20, а также связанный обмен между любыми парами.

Третья версия (V3) была запущена в мае 2021 года и значительно повысила эффективность использования капитала, что позволило поставщикам ликвидности выводить большую часть своей ликвидности из пулов и при этом получать те же вознаграждения (или сжимать капитал в меньших ценовых диапазонах и получать до 4000x прибыли).

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

Эта статья посвящена Uniswap V1, чтобы соблюсти хронологический порядок и лучше понять, какие были улучшения от версии к версии.

Что такое Uniswap?

Uniswap - это децентрализованная криптовалютная биржа (DEX), которая призвана стать альтернативой централизованным биржам. Она работает на блокчейне Ethereum и полностью автоматизирована: здесь нет администраторов, менеджеров или пользователей с привилегированным доступом.

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

Давайте узнаем больше о маркет-мейкерах.

Маркет-мейкеры (рынкоделы, рынкодвижи) - это организации, которые обеспечивают ликвидность (наполенение торговыыми активами) на классических рынках. Ликвидность - это то, что делает возможными сделки: если вы хотите что-то продать, но никто не покупает это, сделки не будет. Некоторые торговые пары имеют высокую ликвидность (например, BTC-USDT), а некоторые - низкую или вообще не имеют ликвидности (например, некоторые мошеннические или сомнительные альткоины).

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

Лучшее решение - позволить каждому стать маркет-мейкером, и именно это делает Uniswap автоматизированным маркет-мейкером: любой пользователь может внести свои средства в торговую пару (и получить от этого выгоду).

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

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

Постоянное соотношение торгуемых пар

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

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

В основе Uniswap лежит формула постоянного соотношения торгуемых пар:

Где x - резерв eth, y - резерв токенов (или наоборот), а k - константа. Uniswap требует, чтобы k оставалось неизменным независимо от того, сколько существует резервов x или y. Когда вы обмениваете eth на токены, вы вкладываете свои eth в смарт-контракт и получаете взамен некоторое количество токенов. Uniswap гарантирует, что после каждой сделки k остается неизменным (на самом деле это не так, позже мы увидим, почему).

Эта формула также отвечает за расчеты цен.

Разработка смарт-контрактов

Чтобы действительно понять, как работает Uniswap, мы создадим его копию. Мы будем писать смарт-контракты на Solidity и использовать HardHat в качестве среды разработки. HardHat - это действительно хороший инструмент, который значительно упрощает разработку, тестирование и развертывание смарт-контрактов.

Настройка проекта

Сначала создайте пустой каталог (я назвал свой zuniswap), перейдите в него по cd и установите HardHat:

$ mkdir zuniswap && cd $_

$ yarn add -D hardhat

Нам также понадобится смарт-контракт для создания токенов, давайте воспользуемся смарт-контрактами ERC20, предоставляемыми OpenZeppelin.

$ yarn add -D @openzeppelin/contracts

Инициализируйте проект HardHat и удалите все из папок contract, script и test.

$ yarn hardhat
...следуйте инструкциям...

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

Токен-контракт

Uniswap V1 поддерживает обмен только между eth и токенами. Поэтому нам нужен смарт-контракт токенов и для этого мы возьмем стандарт ERC20. Давайте напишем его!

// contracts/Token.sol
pragma solidity ^0.8.0;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract Token is ERC20 {
    constructor(
        string memory _name,
        string memory _symbol,
        uint256 _initialSupply
    ) ERC20(_name, _symbol) {
        _mint(msg.sender, _initialSupply);
    }
}

Это все, что нам нужно: мы расширяем смарт-контракт ERC20, предоставленный OpenZeppelin, и определяем собственный конструктор, который позволяет нам задать имя токена (_name), символ (_symbol) и начальное количество токенов (initialSupply). Конструктор также создаёт токены в количестве указано в initialSupply и отправляет их по адресу создателя токена.

Теперь начинается самое интересное!

Смарт-контракт Exchange

Uniswap V1 имеет только два смарт-контракта: Factory и Exchange.

Factory - это смарт-контракт реестра, который позволяет создавать Биржи (Exchange), отслеживает все развернутые Биржи, позволяет находить адрес Биржи по адресу токена и наоборот. При этом сразу стоит отметить, что каждая обмениваемая пара (eth-токен) развертывается как отдельный смарт-контракт Биржи и этот смарт-контракт позволяет обменивать eth на указанный токен и обратно. Таким образом смарт-контракт Exchange определяет логику проведения обмена на Бирже по одному конкретному токену.

Мы создадим смарт-контракт Exchange, а Factory оставим для другой статьи.

Давайте создадим новый пустой смарт-контракт:

// contracts/Exchange.sol
pragma solidity ^0.8.0;

contract Exchange {}

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

contract Exchange  {
  address public tokenAddress;
  constructor(address _token) {
    require(_token != address(0), “Неправильный адрес токена”);
    tokenAddress = _token;
  }
}

Адрес токена - это переменная сохраняемая в состоянии смарт-контракта, что делает её доступной из любой другой функции смарт-контракта. Если сделать ее public, пользователи и разработчики смогут прочитать ее и узнать, с каким токеном связана данная Биржа. В конструкторе мы проверяем, что указан правильный адрес токена (не нулевой адрес), и сохраняем его в переменной состояния.

Обеспечение ликвидности

Как мы уже выяснили, ликвидность делает возможными торги по токенам. Таким образом, нам нужен способ добавить ликвидность в смарт-контракт Биржи:

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";

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

По умолчанию смарт-контракты не могут получать eth, что можно исправить с помощью модификатора payable, который позволяет получать eth в функции: любые eth, отправленные вместе с вызовом функции помеченной как payable, добавляются к балансу смарт-контракта.

Депонирование токенов - это совсем другое дело: поскольку балансы токенов хранятся на смарт-контрактах токенов, мы должны использовать функцию transferFrom (как определено стандартом ERC20) для передачи токенов с адреса отправителя транзакции на смарт-контракт. Кроме того, отправитель транзакции должен будет вызвать функцию approve на смарт-контракте токена, чтобы позволить нашему смарт-контракту Биржи получить свои токены.

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

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

function getReserve() public view returns (uint256) {
        return IERC20(tokenAddress).balanceOf(address(this));
}

И теперь мы можем протестировать addLiquidity, чтобы убедиться, что все правильно:

describe("addLiquidity", async () => {
  it("добавляет ликвидность", async () => {
    await token.approve(exchange.address, toWei(200));
    await exchange.addLiquidity(toWei(200), { value: toWei(100) });
    expect(await getBalance(exchange.address)).to.equal(toWei(100));
    expect(await exchange.getReserve()).to.equal(toWei(200));
  });
});

Сначала мы позволяем смарт-контракту Биржи воспользоваться 200 токенами, вызывая функцию approve. Затем мы вызываем addLiquidity, чтобы внести 200 токенов (для их получения смарт-контракт Биржи вызывает transferFrom) и 100 eth, которые отправляются вместе с вызовом функции. Затем мы убеждаемся, что биржа действительно получила их.

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

Функция ценообразования

Теперь давайте подумаем, как мы будем рассчитывать биржевые цены.

Может возникнуть соблазн думать, что цена - это просто отношение запасов, например:

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

Давайте придерживаться этой идеи и построим функцию ценообразования:

function getPrice(uint256 inputReserve, uint256 outputReserve)  
public pure returns (uint256) {
  require(inputReserve > 0 && outputReserve > 0, "invalid reserves");
  return inputReserve / outputReserve;

}

И давайте проверим это:

describe("getPrice", async () => {

  it("возвращает правильные цены", async () => {
    await token.approve(exchange.address, toWei(2000));
    await exchange.addLiquidity(toWei(2000), { value: toWei(1000) });
    const tokenReserve = await exchange.getReserve();
    const etherReserve = await getBalance(exchange.address);
    // ETH за токен
    expect(
      (await exchange.getPrice(etherReserve, tokenReserve)).toString()
    ).to.eq("0.5");
    // токен за ETH
    expect(await exchange.getPrice(tokenReserve, etherReserve)).to.eq(2);
  });
});

Мы внесли 2000 токенов и 1000 eth и ожидаем, что цена токена составит 0,5 eth, а цена eth - 2 токена. Однако тест провалился: он говорит, что мы получаем 0 eth в обмен на наши токены. Почему так?

Причина в том, что Solidity поддерживает целочисленное деление с округлением до целого. Цена 0,5 округляется до 0! Давайте исправим это, увеличив точность:

function getPrice(uint256 inputReserve, uint256 outputReserve)
        public
        pure
        returns (uint256)
    {
      ...
        return (inputReserve * 1000) / outputReserve;
    }

После обновления теста он пройдет:

// ETH за токен
expect(await exchange.getPrice(etherReserve, tokenReserve)).to.eq(500);

// токен за ETH
expect(await exchange.getPrice(tokenReserve, etherReserve)).to.eq(2000);

Таким образом, теперь 1 токен равен 0,5 eth, а 1 eth равен 2 токенам.

Все выглядит правильно, но что произойдет, если мы обменяем 2000 токенов на eth? Мы получим 1000 eth, а это все, что у нас есть по смарт-контракту! Биржа будет опустошена!

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

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

График функции постоянной суммы

Она пересекает оси x и y, что означает, что она допускает 0 в любой из них! Мы определенно не хотим этого.

Правильная функция ценообразования

Напомним, что Uniswap является маркет-мейкером постоянного соотношения торгуемых пар, что означает, что он основан на формуле постоянного соотношения торгуемых пар:

Дает ли эта формула лучшую функцию ценообразования? Давайте посмотрим.

Формула гласит, что kkk остается постоянным независимо от того, каковы резервы (xxx и yyy). Каждая сделка увеличивает резерв eth или токена и уменьшает резерв токена или eth - давайте запишем эту логику в формулу:

Где Δx - количество eth или токенов, которые мы обмениваем на Δy - количество токенов или eth, которые мы получаем в обмен. Имея эту формулу, мы можем теперь найти Δy:

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

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

Это низкоуровневая функция, поэтому пусть она будет private. Давайте сделаем две высокоуровневые функции-обертки для упрощения вычислений:

function getTokenAmount(uint256 _ethSold) public view returns (uint256) {
        require(_ethSold > 0, "ethSold слишком мал");
        uint256 tokenReserve = getReserve();
        return getAmount(_ethSold, address(this).balance, tokenReserve);
    }

    function getEthAmount(uint256 _tokenSold) public view returns (uint256) {
        require(_tokenSold > 0, "tokenSold слишком мал");
        uint256 tokenReserve = getReserve();
        return getAmount(_tokenSold, tokenReserve, address(this).balance);
    }

И проверим их:

describe("getTokenAmount", async () => {
  it("возвращает правильную сумму токена", async () => {
    ... addLiquidity ...
    let tokensOut = await exchange.getTokenAmount(toWei(1));
    expect(fromWei(tokensOut)).to.equal("1.998001998001998001");
  });
});

describe("getEthAmount", async () => {
  it("возвращает правильную сумму эт", async () => {
    ... addLiquidity ...
    let ethOut = await exchange.getEthAmount(toWei(2));
    expect(fromWei(ethOut)).to.equal("0.999000999000999");
  });
});

Итак, теперь мы получаем 1,998 токена за 1 eth и 0,999 eth за 2 токена. Эти суммы очень близки к тем, которые были получены с помощью предыдущей функции ценообразования. Однако они немного меньше. Почему так?

Формула постоянного соотношения торгуемых пар, на которой мы основывали наши расчеты цен, на самом деле является гиперболой:

Гипербола никогда не пересекает xxx или yyy, поэтому ни один из резервов никогда не равен 0. Это делает резервы бесконечными!

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

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

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

describe("getTokenAmount", async () => {
  it("возвращает правильную сумму токена", async () => {
    ... addLiquidity ...
    let tokensOut = await exchange.getTokenAmount(toWei(1));
    expect(fromWei(tokensOut)).to.equal("1.998001998001998001");
    tokensOut = await exchange.getTokenAmount(toWei(100));
    expect(fromWei(tokensOut)).to.equal("181.818181818181818181");
    tokensOut = await exchange.getTokenAmount(toWei(1000));
    expect(fromWei(tokensOut)).to.equal("1000.0");
  });
});

describe("getEthAmount", async () => {
  it("возвращает правильное количество eth", async () => {
    ... addLiquidity ...
    let ethOut = await exchange.getEthAmount(toWei(2));
    expect(fromWei(ethOut)).to.equal("0.999000999000999");
    ethOut = await exchange.getEthAmount(toWei(100));
    expect(fromWei(ethOut)).to.equal("47.619047619047619047");
    ethOut = await exchange.getEthAmount(toWei(2000));
    expect(fromWei(ethOut)).to.equal("500.0");
  });
});

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

И последнее, что следует отметить: наша первоначальная функция ценообразования, основанная на соотношении резервов, не была ошибочной. На самом деле, она верна, когда количество токенов, которыми мы торгуем, очень мало по сравнению с резервами. Но для создания AMM нам нужно что-то более сложное.

Функция обмена

Теперь мы готовы к реализации обмена.

function ethToTokenSwap(uint256 _minTokens) public payable {
        uint256 tokenReserve = getReserve();
        uint256 tokensBought = getAmount(
            msg.value,
            address(this).balance - msg.value,
            tokenReserve
        );
        require(tokensBought >= _minTokens, "недостаточное количество вывода");
        IERC20(tokenAddress).transfer(msg.sender, tokensBought);
    }

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

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

Наконец, последняя часть кода на сегодня:

function tokenToEthSwap(uint256 _tokensSold, uint256 _minEth) public {
        uint256 tokenReserve = getReserve();
        uint256 ethBought = getAmount(
            _tokensSold,
            tokenReserve,
            address(this).balance
        );
        require(ethBought >= _minEth, "недостаточное количество продукции");
        IERC20(tokenAddress).transferFrom(
            msg.sender,
            address(this),
            _tokensSold
        );
        payable(msg.sender).transfer(ethBought);
    }

Функция передает токены в количестве _tokensSold с баланса пользователя на баланс Брижи и в обмен отправляет пользователю eth в количестве указанном в ethBought.

Заключение

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

  1. Добавление новой ликвидности может вызвать значительные изменения цен.

  2. Поставщики ликвидности не получают вознаграждения; все обмены бесплатны.

  3. Нет возможности удалить ликвидность.

  4. Нет возможности обмениваться токенами ERC20.

  5. Фабрика все еще не реализована.

Мы сделаем это в следующей части.

Серия статей

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

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

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

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

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

  2. Давайте запускать децентрализованные биржи на цепочке так же, как мы запускаем рынки предсказаний". Сообщение на Reddit от Виталика Бутерина, в котором он предложил использовать механику рынков предсказаний для создания децентрализованных бирж. Это дало идею использовать формулу постоянного соотношения торгуемых пар.

  3. Документация Uniswap V1

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

  5. Постоянная функция cоздания рынка: инновации в DeFi

  6. Автоматизированное создание рынка: Теория и практика

Tags:
Hubs:
Total votes 4: ↑4 and ↓0+4
Comments6

Articles