Після запуску Uniswap v4 у головній мережі, механізм Hook став одним із найбільш обговорюваних інновацій у DeFi. Платформа запуску мем-токенів Flaunch на ланцюзі Base використовує Hook для забезпечення фіксованої ціни передпродажу та автоматичного механізму виведення на ринок; протокол ліквідності Bunni v2 застосовує Hook для створення програмованої ліквідності та моделі повторного залучення; у цьому році токени, такі як SATO, uPEG (Unipeg) та Slonks, які грають з механізмом Hook, за короткий час збільшили свою вартість у десятки разів.
На тлі процвітання екосистеми Hook атаки, спрямовані на вади реалізації Hook, значно зросли. У цій статті ми розглянемо механізм Hook Uniswap v4, поступово аналізуючи його основний стек викликів, щоб допомогти проектам зрозуміти можливі вразливості.
Безпека Uniswap v4 Hook
1. Вступ
Найбільш значущою зміною архітектури Uniswap v4 порівняно з v3 є введення механізму Hook (гачків): він дозволяє розробникам підключати власні контракти до подій життєвого циклу ліквідності, впроваджуючи довільну логіку в точках обміну, додавання та зняття ліквідності, ініціалізації тощо.
Нижче наведено кілька ключових змін у версії v4:
Шаблон Singleton: стан усіх пулув керується єдиним контрактом PoolManager, а не окремими контрактами для кожного пулу
- Миттєве бухгалтерське обліку: проміжні зміни балансу під час процесу угоди фіксуються лише у тимчасовому сховищі, а підсумкове звітність здійснюється лише після завершення угоди
- Механізм Hook: кожен пул може бути прив’язаний до контракту Hook, і PoolManager викликає цей контракт у ключових точках (beforeInitialize, beforeSwap, afterAddLiquidity тощо)
- Hook не можна змінити: після ініціалізації пулу прив’язаний адреса Hook фіксована назавжди (адреса Hook, прив’язана до пулу, не може бути змінена, але чи може сам контракт Hook оновлюватися, залежить від його реалізації)

У період v3 розробникам потрібно було довіряти лише самому протоколу Uniswap; у період v4 безпека кожного пулу залежить від прив’язаного до нього Hook. Hook перетворив AMM з фіксованого фінансового примітива на програмову фінансову інфраструктуру, але модель безпеки розбилася з «рівня протоколу» на «рівень пулу».
2. Архітектура Hook
2.1 PoolManager та модель unlock/callback
Ядром версії v4 є одиночний PoolManager. Будь-яка операція зі зміною стану пулу (обмін, додавання або зняття ліквідності) повинна спочатку викликати PoolManager.unlock(), отримати одноразовий доступ до зворотного виклику, а потім виконати конкретні дії в unlockCallback(). На завершення всього процесу PoolManager перевіряє баланс бухгалтерського обліку:

Коли NonzeroDeltaCount != 0, виконується direct revert — це ключове обмеження v4 flash accounting. Будь-який Hook може тимчасово порушити баланс обліку під час виконання, але повинен самостійно вирівняти його до завершення угоди, інакше вся угоду буде відкликана.
Кожен пул унікально ідентифікується структурою PoolKey, яка містить поле hooks:

PoolId обчислюється за допомогою keccak256(PoolKey), тому різні адреси hooks створюють різні пули. Це також означає, що PoolManager не перевіряє, чи використовувалася певна адреса hooks раніше для іншого пулу; один і той самий контракт hooks може бути одночасно прив’язаний до кількох пули.

2.2 Кодування прав доступу Hook у адресі
Однією з неінтуїтивних особливостей v4 є те, що дозволи Hook визначаються не змінною всередині контракту, а адресою розгортання контракту Hook.
PoolManager перевіряє 14 найменш значущих бітів адреси Hook, щоб визначити, чи потрібно викликати цей Hook у певній точці життєвого циклу:

Наприклад, BEFORE_SWAP_FLAG = 1 << 7. Якщо 7-й біт адреси Hook дорівнює 1, PoolManager викликає beforeSwap() цього Hook перед обміном; інакше, навіть якщо контракт Hook реалізує beforeSwap(), він ніколи не буде викликаний PoolManager.
Це означає, що під час розгортання Hook адресу необхідно обчислити за допомогою CREATE2 + salt, щоб сформувати адресу з нижніми бітами, що повністю відповідають цільовим правам. Офіційний Uniswap надає інструмент HookMiner для цієї мети:

Несумісність бітів дозволів із реалізацією функцій призводить до двох типів проблем:
(1) Реалізовано функцію hook, але адреса не закодована відповідними бітами дозволів — PoolManager ніколи не викликатиме цю функцію, логіка є еквівалентом відсутньої
(2) Адреса містить певний біт дозволу, але hook не реалізує відповідну функцію — під час виклику зворотного виклику PoolManager може відмовитися, що призведе до DOS або невдачі перевірки значення, що перешкоджає виконанню відповідних операцій.
Це одночасно є природною перешкодою для оновлення Hook: якщо Hook може бути оновлений через проксі, адреса розгортання не змінюється під час оновлення, тому після оновлення можна змінювати лише реалізацію існуючих функцій Hook, але не додавати нові типи Hook. Щоб забезпечити можливість майбутнього розширення, необхідно на початковому етапі розгортання заздалегідь виділити всі можливі біти дозволів.
2.3 BaseHook та одна загарбна ловушка контролю доступу
У старій версії периферії Uniswap v4 абстрактний контракт BaseHook, який розробники можуть успадковувати для реалізації власних Hook. Однією з ключових функцій BaseHook є надання модифікатора onlyPoolManager для функції unlockCallback():

Але — тут існує дуже легко не помітна конструктивна пастка — ранні версії BaseHook додавали onlyPoolManager лише до unlockCallback, не надаючи жодної захисту для інших callback-функцій hook (beforeSwap, afterSwap, beforeAddLiquidity тощо). Контроль доступу до цих функцій повинен бути явно доданий розробником hook.
3. Огляд коду життєвого циклу Hook
Наведемо повний стек викликів від моменту, коли користувач ініціює обмін з точним введенням, до його розрахунку.
3.1 Ініціалізація пулу та прив’язка Hook
Будь-хто може викликати PoolManager.initialize() для створення нового пулу:

isValidHookAddress перевіряє лише сумісність бітів прав доступу адреси з полем fee, але не перевіряє, чи вже використовується цей Hook в інших пулах, нічого не перевіряє щодо того, чи «згоден» цей Hook приймати цей PoolKey. Якщо під час розробки Hook не було додано логіки білого списку або прив’язки до одного пула в beforeInitialize, будь-хто може створити новий пул із тим самим Hook, але з довільною парою токенів і запустити всі наступні зворотні виклики Hook.
3.2 beforeSwap та BeforeSwapDelta
Вхід у процес обміну — це PoolManager.swap(), який перед виконанням основної логіки обміну викликає Hooks.beforeSwap():

Повернене значення beforeSwap — це кортеж (bytes4, BeforeSwapDelta, uint24):
- bytes4: має дорівнювати IHooks.beforeSwap.selector, інакше PoolManager негайно відміняє
- BeforeSwapDelta: Hook здійснює дельта-корекцію для вказаного та невказаного токенів у цій операції обміну
- uint24: значення динамічної ставки LP (діє лише при увімкненій динамічній ставці для пулу)
BeforeSwapDelta — це псевдонім int256, старші 128 біт — це дельта вказаного токена (тобто токена, кількість якого вказав користувач), молодші 128 біт — це дельта невказаного токена:

Варто звернути увагу, що семантика BeforeSwapDelta полягає в тому, що Hook, який стягує комісію, повинен повертати позитивне значення, а Hook, який повертає токени, — негативне. Розробники легко можуть переплутати знаки; крім того, відповідність між specified та unspecified залежить від знаків params.zeroForOne та amountSpecified, і навіть невелика помилка у реалізації може призвести до помилкового розташування токенів.
PoolManager додає specifiedDelta, повернений beforeSwap, безпосередньо до amountToSwap:

Цей рядок містить ключовий зміст: Hook може перехоплювати суму swap. Коли hookDeltaSpecified дорівнює -params.amountSpecified, amountToSwap безпосередньо стає нульовим, що означає, що Hook повністю бере на себе цей swap — це так званий Async Hook або Custom Curve Hook.
Async Hook — це найбільш ризикованний шаблон проектування у v4: він суттєво замінює логіку обміну Uniswap власною логікою Hook. Якщо Hook містить вразливості або є зловмисним, кошти користувачів більше не захищені вихідною ціноутворюючою логікою Uniswap, а залежать виключно від правильності реалізації самого Hook.
3.3 Дельта-розрахунок та NonzeroDeltaCount
delta, повернені beforeSwap і afterSwap, не спричиняють миттєвого переказу, а замість цього записуються до внутрішнього обліку PoolManager:

Кожного разу, коли накопичена дельта токена змінюється з нуля на ненульове значення, NonzeroDeltaCount збільшується; коли вона знову стає нулем — зменшується. Як зазначено у 2.1, якщо під час завершення unlock() значення NonzeroDeltaCount != 0, вся транзакція відміняється.
Hook балансує свою дельту за допомогою двох дій: settle() (переказ до PoolManager) і take() (вибір з PoolManager):

Механізм забезпечує чітку безпекову семантику: усі повинні в кінцевому підсумку закрити свої позиції. Однак він гарантує лише «збереження балансу», а не «коректність балансу». Якщо Hook у beforeSwap повертає зловмисно сформований delta, PoolManager вірно зараховує цей delta, і якщо в кінці позиція буде закрита, угоду вважається успішною — навіть якщо це означає, що Hook може шляхом підробки бізнес-стану змусити систему помилково вважати, що атакуючий має певні активи, тоді як PoolManager не може виявити такі помилки на рівні бізнес-логіки.
Раніше подія безпеки Cork Protocol виникла через вразливість у його Hook, і до атаки вона вже пройшла аудит чотирма аудиторськими компаніями. Після інциденту ми виявили:
З чотирьох аудитів три не включають контракт CorkHook
- Єдина аудиторська фірма, яка провела аудит CorkHook, виявила деякі проблеми з кодом та надала пропозиції щодо покращення, але не охопила повністю питання контролю доступу
- Інший аудитор у своєму звіті чітко рекомендував: «цікавим наступним завданням було б довести інваріанти для функцій CorkHook, які викликаються різними компонентами, перевіреними в межах цього завдання». Ця рекомендація з позиції аналізу після події має високу цілеспрямованість.
Це виявляє нову аудиторську сліпу зону в епоху v4 Hook: експоненційний ріст складності протоколу робить саме визначення обсягу аудиту важливою безпековою рішенням. Ланцюжок взаємодій між Hook та іншими контрактами протоколу дуже довгий — аудит лише Hook-контракту недостатній для виявлення комбінаційних проблем між контрактами; навпаки, аудит сусідніх контрактів із виключенням Hook із обсягу призводить до пропуску найбільшої поверхні атаки епохи v4.
4. Рефлексія
Порівнюючи механізм протоколу та атаку Cork, можна виділити кілька ключових аспектів безпеки v4 Hook:
(1) Якщо функція зворотного виклику Hook залежить від контексту виклику, наданого PoolManager, слід явно обмежити її виклик лише PoolManager. BaseHook не зробить цього за розробника — це найпоширеніша ловушка проектування, яка суперечить досвіду аудиту контрактів у версії 4.
(2) Зв’язок між хуком та пулом не обмежується PoolManager. Розробники повинні самостійно реалізувати білий список пулів або прив’язку до одного пула у beforeInitialize.
(3) Дозволи адреси Hook повинні точно відповідати реалізації функції. Розрахована адреса повинна містити всі можливі дозволи, які можуть бути додані в майбутньому.
(4) Async / Custom Curve Hook це повністю кастомна реалізація swap. Він не має жодних захистів на рівні протоколу Uniswap і повинен бути аудитований як повністю автономний фінансовий контракт.
(5) Дельта-облік «збереження» не означає «правильність». NonzeroDeltaCount == 0 гарантує лише кінцеву рівновагу обліку, але не гарантує, що вміст обліку не був зловживно змінений
(6) Збивання типів токенів між ринками — це нова поверхня атаки в епоху v4. Коли протокол дозволяє користувачам створювати ринки, семантична перевірка токенів є обов’язковою і не може залежати лише від перевірки інтерфейсу.
Кожен Hook є окремою довіреною зоною, і безпека кожного пулу визначається його прив’язаним Hook. Тому складність аудиту безпеки Hook більше не полягає у «перевірці одного файлу коду», а в «перевірці повного підпротоколу» — ця зміна означає еволюцію методології як для проектів, так і для аудиторів.

