November 10
Оптимизация смарт-контрактов: детальный разбор каждой техники
Вступление: Понимание Gas в Ethereum
Gas в сети Ethereum – это единица измерения вычислительной работы, необходимой для выполнения операций в блокчейне. Каждая операция в смарт-контракте имеет свою "цену" в gas: от простых математических операций до сложных манипуляций с хранилищем. Gas играет две ключевые роли:
- Экономическая защита сети:
- Предотвращает спам и DoS-атаки
- Компенсирует работу майнеров/валидаторов
- Создает рыночный механизм приоритезации транзакций
- Ограничение сложности операций:
Почему оптимизация gas критически важна:
- Финансовый аспект:
- Высокие затраты на gas могут сделать проект нерентабельным
- При масштабировании даже небольшая экономия на транзакцию дает существенный эффект
- В периоды высокой нагрузки сети цена gas может вырасти в десятки раз
- Пользовательский опыт:
- Неоптимизированные контракты могут быть слишком дорогими для пользователей
- Высокие комиссии отпугивают новых пользователей
- Транзакции могут не проходить из-за превышения gas limit
- Технические ограничения:
Стоимость различных операций:
Operation | Gas Cost | Описание -----------------|-----------|------------------ sstore (new) | 20,000 | Запись нового значения в storage sstore (update) | 5,000 | Обновление существующего значения sload | 200 | Чтение из storage mstore | 3 | Запись в memory calldata read | 3 | Чтение входных данных external call | 700 | Внешний вызов контракта log operation | 375 + 8 | Эмиссия события (375 + 8 за байт данных)
Где можно сэкономить больше всего:
1. Базовая структура и хранение данных
Начнем с самого важного - организации хранения данных. Каждый слот хранения в Ethereum стоит дорого, поэтому правильная организация данных критически важна.
1.1 Неоптимизированный вариант:
contract UnoptimizedStorage { // Каждая переменная занимает отдельный слот (32 байта) bool public isActive; // 1 байт, но занимает целый слот uint8 public status; // 1 байт, но занимает целый слот address public owner; // 20 байт, но занимает целый слот uint256 public value; // 32 байта, занимает целый слот }
bool
занимает всего 1 байт, но использует целый слот (32 байта)uint8
также занимает 1 байт, но получает целый слотaddress
занимает 20 байт, но также использует целый слот- Итого: используется 4 слота хранения = 4 * 20,000 gas при первой записи
1.2 Оптимизированный вариант:
contract OptimizedStorage { // Упаковываем переменные в структуру struct PackedData { bool isActive; // 1 байт uint8 status; // 1 байт address owner; // 20 байт } // Всего: 22 байта, помещается в один слот PackedData public data; uint256 public value; // Требует отдельный слот из-за размера }
2. Оптимизация функций передачи токенов
Разберем оптимизированную реализацию функции transfer для ERC20:
contract OptimizedERC20 { // Объявляем события вначале - это хорошая практика event Transfer( address indexed from, // indexed позволяет эффективно фильтровать события address indexed to, // indexed экономит gas при эмиссии событий uint256 value // большие значения лучше не индексировать ); // Используем packed storage для системных параметров struct TokenData { uint8 decimals; // 1 байт - количество десятичных знаков bool paused; // 1 байт - флаг паузы uint16 taxRate; // 2 байта - ставка комиссии (например, 100 = 1%) } // Всего: 4 байта в одном слоте // Основные маппинги - каждый элемент занимает полный слот mapping(address => uint256) private _balances; mapping(address => mapping(address => uint256)) private _allowances; // Оптимизированная функция transfer function transfer(address to, uint256 amount) public returns (bool) { // Сохраняем msg.sender в переменную - экономит gas при множественном использовании address owner = msg.sender; // Проверки безопасности require(to != address(0), "ERC20: transfer to zero"); require(!_tokenData.paused, "Token is paused"); // Загружаем баланс один раз вместо множественных обращений к storage uint256 fromBalance = _balances[owner]; require(fromBalance >= amount, "ERC20: insufficient balance"); // Используем unchecked для экономии gas на проверках переполнения unchecked { _balances[owner] = fromBalance - amount; // Нельзя переполнить uint256 _balances[to] += amount; } // Эмитим событие в конце функции emit Transfer(owner, to, amount); return true; } }
3. Продвинутая оптимизация с использованием Assembly
3.1 Оптимизация проверки адреса:
function isContract(address account) public view returns (bool) { // Стандартный способ ❌ uint256 size; assembly { size := extcodesize(account) } return size > 0; // Оптимизированный способ с assembly ✅ assembly { // Загружаем размер кода напрямую в возвращаемое значение // Экономим на промежуточной переменной return(eq(gt(extcodesize(account), 0), 1)) } }
3.2 Оптимизация работы с массивами:
contract ArrayOptimization { uint256[] private values; // Неоптимизированная функция суммирования ❌ function sumArray() public view returns (uint256) { uint256 sum = 0; for(uint i = 0; i < values.length; i++) { sum += values[i]; } return sum; } // Оптимизированная версия с assembly ✅ function sumArrayOptimized() public view returns (uint256) { assembly { // Получаем указатель на начало массива let length := sload(values.slot) let sum := 0 // Цикл по элементам for { let i := 0 } lt(i, length) { i := add(i, 1) } { // Вычисляем позицию элемента и загружаем его значение sum := add(sum, sload(add(values.slot, i))) } // Возвращаем результат mstore(0x0, sum) return(0x0, 32) } } }
4. Оптимизация условных конструкций
4.1 Использование битовых операций вместо булевых флагов:
contract FlagOptimization { // Неоптимальный способ ❌ mapping(address => bool) public isOperator; mapping(address => bool) public isBlacklisted; mapping(address => bool) public hasKYC; // Оптимальный способ - все флаги в одном uint256 ✅ mapping(address => uint256) public userFlags; // Константы для битовых масок uint256 constant OPERATOR_FLAG = 1; // 2^0 = 1 uint256 constant BLACKLIST_FLAG = 2; // 2^1 = 2 uint256 constant KYC_FLAG = 4; // 2^2 = 4 function setOperator(address user, bool status) external { if (status) { userFlags[user] |= OPERATOR_FLAG; // Установить бит } else { userFlags[user] &= ~OPERATOR_FLAG; // Сбросить бит } } function isUserOperator(address user) external view returns (bool) { return userFlags[user] & OPERATOR_FLAG != 0; } }
5. Оптимизация строковых операций
5.1 Работа со строками через bytes:
contract StringOptimization { // Неоптимальный способ ❌ function concatenateStrings(string memory a, string memory b) public pure returns (string memory) { return string(abi.encodePacked(a, b)); } // Оптимальный способ ✅ function concatenateStringsOptimized(bytes memory a, bytes memory b) public pure returns (bytes memory) { bytes memory result = new bytes(a.length + b.length); assembly { let length := mload(a) mstore(add(result, 32), mload(add(a, 32))) mstore(add(add(result, 32), length), mload(add(b, 32))) } return result; } }
6. Gas-оптимизированные паттерны
6.1 Паттерн Pull Payment вместо Push Payment:
contract PullPayment { mapping(address => uint256) private _pendingPayments; // Плохой вариант: отправка всем получателям ❌ function pushPayments(address[] memory recipients, uint256[] memory amounts) public { for(uint i = 0; i < recipients.length; i++) { (bool success, ) = recipients[i].call{value: amounts[i]}(""); require(success, "Transfer failed"); } } // Хороший вариант: получатели забирают сами ✅ function addPendingPayment(address recipient, uint256 amount) public { _pendingPayments[recipient] += amount; } function withdrawPayment() public { uint256 amount = _pendingPayments[msg.sender]; require(amount > 0, "No pending payments"); _pendingPayments[msg.sender] = 0; (bool success, ) = msg.sender.call{value: amount}(""); require(success, "Transfer failed"); } }
7. Практические советы по оптимизации
1. Порядок объявления переменных:
// Неоптимальный модификатор ❌ modifier checkRole(bytes32 role) { require(hasRole(role, msg.sender), "Invalid role"); _; } // Оптимальный модификатор ✅ modifier checkRole(bytes32 role) { if (!hasRole(role, msg.sender)) revert InvalidRole(); _; }
3. Использование custom errors вместо require:
// До Solidity 0.8.4 ❌ require(balance >= amount, "Insufficient balance"); // После Solidity 0.8.4 ✅ error InsufficientBalance(uint256 available, uint256 required); if (balance < amount) { revert InsufficientBalance(balance, amount); }
8. Инструменты для анализа gas-затрат
require("hardhat-gas-reporter"); module.exports = { gasReporter: { currency: 'USD', gasPrice: 21, enabled: process.env.REPORT_GAS ? true : false } };
require("solidity-coverage"); module.exports = { solidity: { settings: { optimizer: { enabled: true, runs: 200 } } } };
Заключение
При оптимизации смарт-контрактов важно помнить:
- Storage оптимизация:
- Упаковывайте переменные в структуры
- Используйте минимально необходимые типы данных
- Группируйте связанные данные
- Вычислительная оптимизация:
- Используйте assembly для критических операций
- Применяйте битовые операции где возможно
- Избегайте повторных вычислений
- Безопасность:
Полезные ресурсы
- Ethereum Yellow Paper
- Solidity Documentation
- OpenZeppelin Contracts
- EVM Codes - справочник по opcodes
- Hardhat Documentation
Подпишись !!!
Спасибо за чтение ! Подпишись что бы не пропускать дальнейшие статьи!
Телеграм: https://t.me/one_eyes