Перейти до основного вмісту

Детальний огляд контракту Юнісвоп-v2

Solidity
децентралізовані застосунки
Середній рівень
Орі Померанц
1 травня 2021 р.
54 хвилин на читання
Редагувати сторінку (opens in a new tab)

Вступ

Юнісвоп v2 (opens in a new tab) може створити ринок обміну між будь-якими двома токенами ERC-20. У цій статті ми розглянемо вихідний код контрактів, які реалізують цей протокол, і з'ясуємо, чому вони написані саме так.

Що робить Юнісвоп?

По суті, існує два типи користувачів: постачальники ліквідності та трейдери.

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

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

Коли постачальники ліквідності хочуть повернути свої активи, вони можуть спалити токени пулу та отримати назад свої токени, включно зі своєю часткою винагород.

Натисніть тут для більш детального опису (opens in a new tab).

Чому v2? Чому не v3?

Юнісвоп v3 (opens in a new tab) — це оновлення, яке набагато складніше за v2. Легше спочатку вивчити v2, а потім переходити до v3.

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

Юнісвоп v2 поділяється на два компоненти: основний та периферійний. Цей поділ дозволяє основним контрактам, які зберігають активи і тому повинні бути безпечними, залишатися простішими та легшими для аудиту. Уся додаткова функціональність, необхідна трейдерам, може бути забезпечена периферійними контрактами.

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

Ось потік даних та управління, який відбувається під час виконання трьох основних дій в Uniswap:

  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) реалізує фактичний пул, який обмінює токени. Це основна функціональність Uniswap.

Це всі інтерфейси, про які контракту потрібно знати, або тому, що контракт їх реалізує (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. Рішення, яке знайшов Uniswap, полягає у використанні 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). Uniswap потрібно переказувати довільні токени ERC-20, що означає виклик контрактів ERC-20, які можуть спробувати зловживати ринком Uniswap, що їх викликає. Маючи змінну 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 token1 назад1,050.000952.3815,0200.90720
Трейдер B вносить 10 token0 і отримує 8.984 token1 назад1,060.000943.3965,0300.89020+10*0.907 = 29.07
Трейдер C вносить 40 token0 і отримує 34.305 token1 назад1,100.000909.0905,1000.82629.07+70*0.890 = 91.37
Трейдер D вносить 100 token1 і отримує 109.01 token0 назад990.9901,009.0905,1101.01891.37+10*0.826 = 99.63
Трейдер E вносить 10 token0 і отримує 10.079 token1 назад1,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) {

У Uniswap 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 (інтерфейс прикладного програмування) для Юнісвоп. Вони доступні для зовнішніх викликів, як з інших контрактів, так і з децентралізованих застосунків. Ви можете викликати базові контракти безпосередньо, але це складніше, і ви можете втратити кошти, якщо зробите помилку. Базові контракти містять лише перевірки, щоб переконатися, що їх не обманюють, а не перевірки на адекватність для будь-кого іншого. Останні знаходяться в периферії, тому їх можна оновлювати за потреби.

UniswapV2Router01.sol

Цей контракт (opens in a new tab) має проблеми, і його більше не слід використовувати (opens in a new tab). На щастя, периферійні контракти не мають стану (stateless) і не зберігають жодних активів, тому легко відмовитися від нього та запропонувати людям використовувати заміну, UniswapV2Router02.

UniswapV2Router02.sol

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

Більшість із них ми або зустрічали раніше, або вони є досить очевидними. Єдиним винятком є IWETH.sol. Uniswap 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).

Останнє оновлення сторінки: 3 квітня 2026 р.