November 10, 2024
Оптимизация смарт-контрактов: детальный разбор каждой техники
Вступление: Понимание 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