Введение
Лучший способ научиться чему‑то — научить других. Второй лучший способ научиться чему‑то — сделать это самому. Я решил объединить эти два способа и научить себя и вас программировать 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 токенам.