Оптимізація газу смарт-контрактів
Газ — це не абстракція, це гроші. При ціні газу 30 gwei та ETH по $3000, одна транзакція з 200,000 gas коштує $18. Помножте на тисячі користувачів в день — та різниця між оптимізованим і неоптимізованим контрактом вимірюється в сотнях тисяч доларів на рік. На L2 газ дешевше, але calldata все ще дорогий та оптимізація залишається важливою.
Оптимізація газу — це не «зробити код красивіше». Це знання EVM opcodes та їхньої вартості, паттерни зберігання даних та вміння читати gas report у Foundry. Розберемо ключові техніки.
Storage: головне джерело витрат
SSTORE та SLOAD вартість
Запис у storage (SSTORE) — один з найдорожчих opcodes: 20,000 gas для запису у новий слот, 5,000 gas для зміни існуючого (холодний), 100 gas для повторної зміни у межах однієї транзакції (теплий, EIP-2929).
Читання (SLOAD) — 2,100 gas холодний, 100 gas теплий. Якщо читаєте той же слот storage кілька разів у функції — кешуйте у змінну memory:
// Погано: два SLOADs
function bad() external view returns (uint256) {
return balances[msg.sender] + balances[msg.sender] / 100;
}
// Добре: один SLOAD
function good() external view returns (uint256) {
uint256 balance = balances[msg.sender]; // один SLOAD
return balance + balance / 100;
}
Пакування Storage
EVM слот — 32 байти. Якщо у вас кілька змінних менше 32 байт — Solidity упакує їх в один слот, якщо вони оголошені поряд. Це зменшує кількість SLOAD/SSTORE.
// Погано: три окремих слота
uint256 a; // слот 0
uint128 b; // слот 1 (неефективно — займає весь слот)
uint128 c; // слот 2 (неефективно)
// Добре: b та c упаковані в один слот
uint256 a; // слот 0
uint128 b; // слот 1 (перші 16 байт)
uint128 c; // слот 1 (наступні 16 байт)
Для struct це особливо важливо: порядок полів у struct впливає на кількість слотів storage. Групуйте маленькі типи разом.
Mapping vs Array
Mapping дешевше для довільного доступу: O(1) та один SLOAD. Array з пошуком — O(N) та N SLOADs. Якщо вам потрібно «чи є X у списку» — використовуйте mapping(address => bool), не array.
Ітерація по mapping on-chain неможлива нативно (немає способу отримати всі ключі). Якщо потрібна ітерація — EnumerableSet з OpenZeppelin (зберігає обидва: mapping для O(1) пошуку та array для ітерації).
Calldata та функції
Calldata vs Memory
Параметри функції позначені calldata не копіюються у memory — читаються безпосередньо з calldata. Для масивів та рядків це значна економія:
// memory: копіює весь масив — дорого
function processMemory(uint256[] memory data) external { ... }
// calldata: без копіювання — дешевше
function processCalldata(uint256[] calldata data) external { ... }
Різниця зростає з розміром даних. Для великого масиву — економія сотень тисяч gas.
Користувацькі помилки vs Require Strings
До Solidity 0.8.4 помилки передавалися як рядок, кодовані у calldata та зберігалися у bytecode. Користувацькі помилки — дешевше як при розгортанні, так і при revert:
// Старий спосіб: дорого
require(amount > 0, "Amount must be positive");
// Користувацька помилка: дешевше
error InvalidAmount(uint256 amount);
if (amount == 0) revert InvalidAmount(amount);
Користувацька помилка економить ~200-500 gas на кожному revert та зменшує розмір bytecode (менше string literals).
Короткі функції та вбудовування
Кожний внутрішній виклик функції — додатковий overhead (JUMP opcodes, управління стеком). Компілятор Solidity з --via-ir (Yul IR) краще оптимізує вбудовування малих функцій. Увімкнення viaIR: true у foundry.toml/hardhat.config може зменшити gas на 5-15% без змін коду.
Паттерни оптимізації
Упаковані struct для масових операцій
При обробці масиву об'єктів, структура даних впливає на слоти storage на елемент:
| Структура | Слотів на елемент | Gas на елемент |
|---|---|---|
| Три поля uint256 | 3 слота | ~60,000 gas запису |
| uint128 + uint64 + uint64 | 1 слот | ~20,000 gas запису |
| Bitmap флаги | 1 слот / 256 елементів | ~78 gas на флаг |
Bitmap для булевих флагів: uint256 flags зберігає 256 флагів в одному слоті. Операція flags |= (1 << bitIndex) — 100 gas замість 20,000 для окремого mapping(uint => bool).
Ледача ініціалізація
Не ініціалізуйте змінні значенням за замовчуванням — це марна трата gas:
// Непотрібний SSTORE з нулем (Solidity робить це)
uint256 public counter = 0; // не потрібно
// Просто
uint256 public counter;
Невідмічена арифметика
Solidity 0.8+ додав переповнення/недоповнення перевірки до кожної арифметичної операції (+100-200 gas). Якщо ви впевнені що переповнення неможливо — використовуйте unchecked:
// Стандартний цикл з відміченою арифметикою
for (uint256 i = 0; i < arr.length; i++) { ... }
// Оптимізований з невідміченим збільшенням
for (uint256 i = 0; i < arr.length; ) {
// ... логіка
unchecked { ++i; } // префіксний ++ теж дешевше ніж постфіксний
}
Економія на типовому циклі з 100 елементами — 5,000-10,000 gas.
Інструменти вимірювання
Снимки Foundry gas — forge snapshot створює файл .gas-snapshot з використанням gas на тест. forge snapshot --diff показує зміну після виправлень. Необхідне для ітеративної оптимізації.
forge test --gas-report — таблиця з середнім/мінімальним/максимальним gas на функцію.
ETH Gas Station / Tenderly — симуляція транзакції з розбором по opcodes. Корисно для розуміння де саме витрачається gas.
Процес аудиту та оптимізації
- Базова вимірювання:
forge snapshotдо змін - Профілювання: визначити найдорожчі функції
- Аналіз storage: перевірити упаковку структури, непотрібні SLOADs
- Застосувати оптимізації: за пріоритетом (storage → calldata → арифметика)
- Верифікація:
forge snapshot --diff, перевірити що функціональність не зламана
Типовий результат: 20-40% зменшення gas для неоптимізованого контракту, 10-20% для вже «розумного» коду. Для DeFi з високим навантаженням транзакцій це прямої економії користувачам.
Часова шкала: аудит + звіт + реалізація оптимізацій — 2-4 тижні залежно від розміру кодової бази.







