Deep EVM #3: Понимание газа — почему ваш контракт стоит столько
Engineering Team
Газ: экономическая модель EVM
Газ в Ethereum — это не просто техническое ограничение. Это экономическая система, которая ценит вычислительные ресурсы, предотвращает злоупотребления и определяет, сколько стоит ваш контракт для пользователей. Каждый опкод, каждое обращение к памяти, каждая запись в хранилище — всё измеряется в газе.
Понимание газа на глубоком уровне — это разница между контрактом, который стоит $2 за транзакцию, и контрактом, который стоит $0.20. В мире DeFi и MEV это разница между прибыльным и убыточным бизнесом.
Анатомия стоимости транзакции
Стоимость транзакции в Ethereum складывается из нескольких компонентов:
1. Внутренний газ (Intrinsic Gas)
Каждая транзакция начинается с базовой стоимости:
| Компонент | Стоимость |
|---|---|
| Базовая стоимость транзакции | 21000 газа |
| Ненулевой байт calldata | 16 газа |
| Нулевой байт calldata | 4 газа |
| Создание контракта (CREATE) | 32000 газа |
| Список доступа: адрес | 2400 газа |
| Список доступа: слот хранилища | 1900 газа |
Для простого перевода ETH (нет calldata) это ровно 21000 газа. Для вызова transfer(address,uint256) на ERC-20 — 21000 + стоимость calldata + стоимость выполнения.
Calldata для transfer:
// 4 байта селектора + 32 байта адреса + 32 байта суммы = 68 байт
// Типичная стоимость: ~16 * 40 (ненулевых) + 4 * 28 (нулевых) = 752 газа
2. Стоимость выполнения
Это сумма газа всех выполненных опкодов. Для типичного ERC-20 transfer:
// Примерная разбивка стоимости ERC-20 transfer:
// Диспетчеризация функций: ~100 газа
// Загрузка баланса отправителя: ~2100 газа (холодный SLOAD)
// Проверка достаточности: ~10 газа
// Обновление баланса отправителя: ~2900 газа (тёплый SSTORE, ненулевое->ненулевое)
// Загрузка баланса получателя: ~2100 газа (холодный SLOAD)
// Обновление баланса получателя: ~20000 или 2900 газа (зависит от нулевого/ненулевого)
// Эмиссия события Transfer: ~1500 газа (LOG3 + данные)
// Возврат значения: ~50 газа
// Итого: ~29000-51000 газа
Обратите внимание на огромную разницу: если получатель уже имел баланс (ненулевое->ненулевое SSTORE: 2900), транзакция на ~17000 газа дешевле, чем если получатель новый (нулевое->ненулевое: 20000).
3. EIP-1559: базовая ставка и приоритет
С обновления London (август 2021) стоимость газа определяется двумя компонентами:
общая стоимость = газ_использовано * (базовая_ставка + приоритетная_ставка)
- Базовая ставка (base fee) — алгоритмически определяется протоколом. Увеличивается, когда блоки заполнены более чем наполовину; уменьшается, когда менее. Сжигается полностью.
- Приоритетная ставка (priority fee / tip) — платёж валидатору за включение транзакции. В периоды низкой нагрузки ~1-2 gwei; при высокой ~20-100+ gwei.
Почему SSTORE так дорог
Запись в хранилище — самая дорогая операция в EVM. Вот почему:
Дерево состояний Ethereum
Ethereum хранит всё состояние в модифицированном Merkle Patricia Trie. Каждая запись в хранилище:
- Обновляет значение в дереве хранилища контракта.
- Пересчитывает хеши от листа до корня (O(log n) хеш-операций).
- Обновляет корень хранилища контракта в дереве состояний аккаунтов.
- Пересчитывает хеши дерева аккаунтов до корня.
Для записи нового значения (0->ненулевое) нужно создать новый узел-лист — 20000 газа. Для обновления существующего (ненулевое->ненулевое) — 2900 газа (тёплый), потому что лист уже существует.
Практический пример: маппинг vs массив
// Сценарий: хранение 100 адресов
// Вариант A: mapping (каждый адрес = отдельный слот)
mapping(uint256 => address) public addresses;
// Запись 100 адресов: 100 * 22100 = 2 210 000 газа (все холодные, 0->ненулевое)
// Вариант B: массив (упакованные данные)
address[] public addresses;
// Запись 100 адресов: массив уже инициализирован, ~100 * 5000 = 500 000 газа
// Но первоначальная инициализация длины массива: 22100 газа
Паттерны оптимизации газа
1. Кэширование слотов хранилища
Самая частая и эффективная оптимизация — кэширование значений хранилища в локальных переменных:
// ПЛОХО: множественные чтения хранилища
function bad(uint256 amount) external {
require(balances[msg.sender] >= amount); // SLOAD #1
balances[msg.sender] -= amount; // SLOAD #2 + SSTORE
totalSupply -= amount; // SLOAD #3 + SSTORE
emit Transfer(msg.sender, address(0), amount);
require(balances[msg.sender] == 0 || balances[msg.sender] > minBalance); // SLOAD #4
}
// ХОРОШО: кэширование в памяти
function good(uint256 amount) external {
uint256 senderBalance = balances[msg.sender]; // SLOAD #1 (единственный)
require(senderBalance >= amount);
uint256 newBalance = senderBalance - amount;
balances[msg.sender] = newBalance; // SSTORE
totalSupply -= amount; // SLOAD #2 + SSTORE
emit Transfer(msg.sender, address(0), amount);
require(newBalance == 0 || newBalance > minBalance); // Бесплатно — из стека
}
// Экономия: ~2200 газа (2 холодных SLOAD)
2. Упаковка переменных
Solidity упаковывает переменные меньше 32 байт в один слот:
// ПЛОХО: 3 слота (3 * 2100 = 6300 газа для чтения)
contract Bad {
uint8 a; // слот 0
uint256 b; // слот 1 (не упаковывается с uint8 из-за размера)
uint8 c; // слот 2
}
// ХОРОШО: 2 слота (2 * 2100 = 4200 газа для чтения)
contract Good {
uint8 a; // слот 0
uint8 c; // слот 0 (упакован с a)
uint256 b; // слот 1
}
// Экономия: 2100 газа за каждое чтение
3. Использование calldata вместо memory
// ПЛОХО: копирует весь массив в память
function sumBad(uint256[] memory arr) external pure returns (uint256 total) {
for (uint256 i; i < arr.length; i++) {
total += arr[i];
}
}
// ХОРОШО: читает напрямую из calldata
function sumGood(uint256[] calldata arr) external pure returns (uint256 total) {
for (uint256 i; i < arr.length; i++) {
total += arr[i];
}
}
// Экономия: ~60 газа за элемент + стоимость расширения памяти
4. Использование unchecked для безопасной арифметики
Начиная с Solidity 0.8, арифметические операции проверяют переполнение по умолчанию. Каждая проверка стоит ~100 газа:
// С проверками (по умолчанию в Solidity 0.8+)
function withChecks(uint256 a, uint256 b) external pure returns (uint256) {
return a + b; // ~130 газа (ADD + проверка переполнения)
}
// Без проверок (когда переполнение невозможно)
function withoutChecks(uint256 a, uint256 b) external pure returns (uint256) {
unchecked {
return a + b; // ~30 газа (только ADD)
}
}
// Типичное применение: счётчик цикла
for (uint256 i; i < length;) {
// ... тело цикла ...
unchecked { ++i; } // Экономия ~100 газа за итерацию
}
5. Короткое замыкание условий
Solidity использует ленивое вычисление для && и ||. Порядок условий имеет значение:
// ПЛОХО: дорогое условие проверяется первым
require(expensiveCheck() && cheapCheck());
// ХОРОШО: дешёвое условие проверяется первым
require(cheapCheck() && expensiveCheck());
// Если cheapCheck() ложно, expensiveCheck() не выполняется
Лимит газа блока и его влияние
Лимит газа блока в Ethereum — 30 миллионов газа (целевой — 15 миллионов). Это означает:
- Максимум ~1500 простых переводов ETH в одном блоке (30M / 21000)
- Максимум ~500-800 ERC-20 трансферов в одном блоке
- Один сложный DeFi-своп может потреблять 200000-500000 газа
Для разработчиков MEV-ботов это создаёт прямое давление: чем меньше газа потребляет ваш бот, тем больше прибыли остаётся после оплаты газа. Бот, потребляющий 100000 газа вместо 200000, имеет вдвое большую маржу.
Профилирование газа: инструменты
Для точного анализа газа используйте следующие инструменты:
- Foundry gas reports —
forge test --gas-reportпоказывает потребление газа для каждой функции. - Hardhat gas reporter — плагин, генерирующий таблицу газа для всех вызовов в тестах.
- Tenderly — визуальный дебаггер транзакций с разбивкой газа по опкодам.
- EVM Playground — интерактивная среда для экспериментов с опкодами.
# Foundry: запуск тестов с отчётом о газе
forge test --gas-report
# Результат:
# | Contract | Function | Min | Avg | Max | # calls |
# |----------|----------|-----|------|------|---------|
# | Token | transfer | 29k | 45k | 51k | 100 |
# | Token | approve | 24k | 24k | 24k | 50 |
EIP-4844: блобы и газ данных
EIP-4844 (обновление Dencun, март 2024) ввёл новый тип транзакции с блобами — большими фрагментами данных (до 128 КБ), используемыми L2-роллапами для публикации данных. Блобы имеют отдельный рынок газа с собственной базовой ставкой, значительно снижая стоимость данных для роллапов.
Это фундаментально изменило экономику L2: стоимость публикации данных на L1 упала в 10-100 раз, что напрямую снижает стоимость транзакций для пользователей L2.
Заключение
Газ — это сердце экономической модели Ethereum. Понимание его механизмов — от внутреннего газа транзакции до квадратичной стоимости памяти и дифференцированной стоимости хранилища — позволяет писать контракты, которые экономят пользователям реальные деньги. Каждая из описанных оптимизаций — кэширование хранилища, упаковка переменных, использование calldata — это не микрооптимизация, а архитектурное решение.
В следующей статье мы перейдём к безопасности: как msg.sender, контроль доступа и защита от реентрабельности работают на уровне EVM.