Оптимизация газа смарт-контрактов
Газ — это не абстракция, это деньги. При цене газа 30 gwei и ETH по $3000, одна транзакция с 200,000 gas стоит $18. Умножьте на тысячи пользователей в день — и разница между оптимизированным и неоптимизированным контрактом измеряется в сотнях тысяч долларов в год. На L2 газ дешевле, но calldata всё ещё дорог и оптимизация остаётся важной.
Оптимизация газа — это не "сделать код красивее". Это знание EVM opcodes и их стоимости, паттерны хранения данных, и умение читать gas report в Foundry. Разберём ключевые техники.
Storage: главный источник расходов
SSTORE и SLOAD стоимость
Запись в storage (SSTORE) — один из самых дорогих opcodes: 20,000 gas для записи в новый slot, 5,000 gas для изменения существующего (cold), 100 gas для повторного изменения в рамках одной транзакции (warm, EIP-2929).
Чтение (SLOAD) — 2,100 gas cold, 100 gas warm. Если читаете один и тот же storage slot несколько раз в функции — кешируйте в memory переменную:
// Плохо: два SLOAD
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 packing
EVM slot — 32 байта. Если у вас несколько переменных меньше 32 байт — Solidity упакует их в один slot, если они объявлены рядом. Это снижает количество SLOAD/SSTORE.
// Плохо: три отдельных slot
uint256 a; // slot 0
uint128 b; // slot 1 (неэффективно — занимает целый slot)
uint128 c; // slot 2 (неэффективно)
// Хорошо: b и c упакованы в один slot
uint256 a; // slot 0
uint128 b; // slot 1 (первые 16 байт)
uint128 c; // slot 1 (следующие 16 байт)
Для struct это особенно важно: порядок полей внутри struct влияет на количество storage slots. Группируйте маленькие типы вместе.
Mapping vs Array
Mapping дешевле Array для произвольного доступа: O(1) и один SLOAD. Array с поиском по значению — O(N) и N SLOAD-ов. Если вам нужно "есть ли X в списке" — используйте mapping(address => bool), не array.
Итерация по mapping on-chain невозможна нативно (нет способа получить все ключи). Если нужна итерация — EnumerableSet из OpenZeppelin (хранит оба: mapping для O(1) lookup и array для итерации).
Calldata и функции
Calldata vs Memory
Параметры функции помеченные calldata не копируются в memory — они читаются напрямую из calldata. Для массивов и строк это значительная экономия:
// memory: копирует весь массив — дорого
function processMemory(uint256[] memory data) external { ... }
// calldata: нет копирования — дешевле
function processCalldata(uint256[] calldata data) external { ... }
Разница растёт с размером данных. Для большого массива — экономия сотен тысяч gas.
Custom errors vs require strings
До Solidity 0.8.4 ошибки передавались как string, который кодируется в calldata и хранится в bytecode. Custom errors — дешевле и в deploy, и в revert:
// Старый способ: дорого
require(amount > 0, "Amount must be positive");
// Custom error: дешевле
error InvalidAmount(uint256 amount);
if (amount == 0) revert InvalidAmount(amount);
Custom error экономит ~200-500 gas на каждом revert, и уменьшает размер bytecode (меньше string literals).
Короткие функции и inlining
Каждый internal вызов функции — дополнительный overhead (JUMP opcodes, stack management). Компилятор Solidity с --via-ir (Yul IR) лучше оптимизирует inlining маленьких функций. Включение viaIR: true в foundry.toml/hardhat.config может снизить gas на 5-15% без изменений кода.
Паттерны оптимизации
Packed structs для batch операций
При обработке массива объектов, структура данных влияет на количество storage slots:
| Структура | Slots per item | Gas per item |
|---|---|---|
| Три uint256 поля | 3 slots | ~60,000 gas write |
| uint128 + uint64 + uint64 | 1 slot | ~20,000 gas write |
| Bitmap флаги | 1 slot / 256 items | ~78 gas per flag |
Bitmap для boolean флагов: uint256 flags хранит 256 флагов в одном slot. Операция flags |= (1 << bitIndex) — 100 gas вместо 20,000 для отдельного mapping(uint => bool).
Lazy initialization
Не инициализируйте переменные значением по умолчанию — это трата gas:
// Лишний SSTORE с нулём (Solidity делает это сам)
uint256 public counter = 0; // не нужно
// Просто
uint256 public counter;
Unchecked arithmetic
Solidity 0.8+ добавил overflow/underflow checks к каждой арифметической операции (+100-200 gas). Если вы уверены что overflow невозможен — используйте unchecked:
// Стандартный цикл с checked arithmetic
for (uint256 i = 0; i < arr.length; i++) { ... }
// Оптимизированный с unchecked increment
for (uint256 i = 0; i < arr.length; ) {
// ... logic
unchecked { ++i; } // prefix ++ тоже дешевле postfix
}
Экономия на типичном цикле по 100 элементам — 5,000-10,000 gas.
Инструменты измерения
Foundry gas snapshots — forge snapshot создаёт .gas-snapshot файл с gas usage для каждого теста. forge snapshot --diff показывает изменение после правок. Незаменимо для итеративной оптимизации.
forge test --gas-report — таблица с average/min/max gas для каждой функции.
ETH Gas Station / Tenderly — симуляция транзакций с breakdown по opcodes. Полезно для понимания где именно тратится gas.
Процесс аудита и оптимизации
- Baseline measurement:
forge snapshotдо изменений - Profiling: определить самые дорогие функции
- Storage analysis: проверить упаковку структур, лишние SLOAD
- Apply optimizations: по приоритету (storage → calldata → arithmetic)
- Verification:
forge snapshot --diff, проверить что функциональность не сломалась
Типичный результат: 20-40% снижение gas для не оптимизированного контракта, 10-20% для уже "разумного" кода. Для DeFi с высокой транзакционной нагрузкой это прямая экономия пользователям.
Срок работы: аудит + отчёт + реализация оптимизаций — 2-4 недели в зависимости от размера кодовой базы.







