Рефакторинг смарт-контрактів
Контракт написаний рік тому назад, працює, гроші не теряються — але кожна нова функція викликає паніку: незрозуміло, що сломається. Storage layout розпухнув до 30 змінних без логіки групування, функції на 200 рядків, жодного тесту на edge cases. Рефакторинг смарт-контракту відрізняється від рефакторингу звичайного коду тим, що ціна помилки — втрата коштів користувачів.
Де найчастіше ховається технічний борг
Неоптимізований storage layout. Solidity упаковує змінні в 32-байтні слоти. Якщо змінні оголошені у порядку uint128, uint256, uint128 — це три слоти замість двох. На популярному контракті з тисячами викликів на день це реальні гроші. Бачили контракт, де переупорядкування 8 змінних під slot packing скоротило газ на write-операції на 40%. Це не оптимізація заради оптимізації — це конкретні тисячі доларів економії користувачів на рік.
Unbounded loops як вектор gas griefing. Паттерн for (uint i = 0; i < users.length; i++) у контракті, де users може зростати необмежено — це не просто неефективність. Зловмисник додає 10 000 адрес, і наступний виклик distribute() вилітає за ліміт блока (30M газу на mainnet). Функція стає невиконуваною — контракт зависає. Рефакторинг на pull-паттерн з пагінацією або enumerable mapping вирішує це структурно.
Reentrancy без guard на cross-function рівні. ReentrancyGuard від OpenZeppelin захищає одну функцію. Але якщо withdraw() захищена guard, а claim() ні — й обидві змінюють один balance mapping — cross-function reentrancy можлива. Саме так працював Fei Protocol exploit (80M$ у 2022). При рефакторингу аудируємо весь граф викликів, а не тільки "підозрілі" функції.
Як ми підходимо до рефакторингу
Перший крок — статичний аналіз через Slither. Він за 2-3 хвилини знаходить:
- reentrancy паттерни (включаючи cross-function)
- неініціалізовані змінні
- tx.origin авторизацію
- неправильний порядок операцій (state change після external call)
- shadow змінні
Slither дає сотні warning-ів на будь-якому реальному контракті — важливо відділити критичні від інформаційних. Далі — Mythril для символічного виконання на ключових функціях.
Coverage аудит. Дивимось, що покрито тестами, а що ні. Як правило: happy path покритий, edge cases — ні. Нема тесту на "що буде, якщо owner викличе цю функцію двічі поспіль". Нема тесту на "що буде з контрактом після emergency pause". Додаємо тести через Foundry — його fuzzer за годину знаходить те, що ручні тести на Hardhat не знайшли за місяць.
Структурний рефакторинг. Виносимо логіку в бібліотеки (Library pattern), розділяємо storage та logic через Diamond pattern (EIP-2535) якщо контракт великий, застосовуємо Check-Effects-Interactions на кожній функції з external call. Переписуємо Events — неправильно індексовані параметри роблять The Graph запити неефективними.
Gas оптимізація. Конкретні паттерни:
-
storage→memoryдля read-only операцій всередині функції -
uint256замістьuint8у локальних змінних (EVM оперує 256-бітними словами, downcast дорожче) -
unchecked { i++ }у лічильниках циклів, де overflow неможливий (Solidity 0.8+) -
calldataзамістьmemoryдля параметрів зовнішніх функцій - упаковка подій: не емітити зайві поля в events
| Паттерн | Економія газу (приблизно) |
|---|---|
| Slot packing змінних | 20-40% на SSTORE |
| memory замість storage у функції | 15-30% на читання |
| unchecked increment | 60-80 газу на ітерацію |
| calldata замість memory | 50-100 газу на аргумент |
| Custom errors замість require strings | 50-200 газу на revert |
Апгрейд версії Solidity
Рефакторинг часто включає міграцію з 0.6/0.7 на 0.8+. Основні зміни:
- Arithmetic overflow/underflow перевіряється за замовчуванням (SafeMath можна видалити)
- Custom errors через
errorkeyword — дешевше та інформативніше ніжrevert("string") - Immutable змінні — економлять газ на константах, які встановлюються в конструкторі
Міграція з 0.6 на 0.8 — це не просто заміна pragma. ABI encoding змінився, деякі паттерни з assembly перестали працювати, .call.value() замінений на .call{value:}(). Тестуємо кожну зміну ізольовано.
Процес роботи
День 1. Статичний аналіз (Slither, Mythril), coverage звіт, складання реєстру проблем з пріоритетами.
Дні 2-3. Рефакторинг за пріоритетами: критичні security issues → gas оптимізація → readability. Кожен PR — ізольоване змінення з тестами. Жодних "один великий коміт з 50 змінами".
Фінал. Запуск Foundry fuzz tests на рефакторингових функціях, порівняння gas report до/після через forge snapshot.
Термін — 2-3 дні для контракту до 500 рядків. Складніші системи з кількох контрактів — до тижня.







