November 10

Оптимизация смарт-контрактов: детальный разбор каждой техники

Вступление: Понимание Gas в Ethereum

Gas в сети Ethereum – это единица измерения вычислительной работы, необходимой для выполнения операций в блокчейне. Каждая операция в смарт-контракте имеет свою "цену" в gas: от простых математических операций до сложных манипуляций с хранилищем. Gas играет две ключевые роли:

  1. Экономическая защита сети:
    • Предотвращает спам и DoS-атаки
    • Компенсирует работу майнеров/валидаторов
    • Создает рыночный механизм приоритезации транзакций
  2. Ограничение сложности операций:
    • Каждый блок имеет лимит gas (gas limit)
    • Предотвращает бесконечные циклы и сложные вычисления
    • Обеспечивает предсказуемое время выполнения транзакций

Почему оптимизация gas критически важна:

  1. Финансовый аспект:
    • Высокие затраты на gas могут сделать проект нерентабельным
    • При масштабировании даже небольшая экономия на транзакцию дает существенный эффект
    • В периоды высокой нагрузки сети цена gas может вырасти в десятки раз
  2. Пользовательский опыт:
    • Неоптимизированные контракты могут быть слишком дорогими для пользователей
    • Высокие комиссии отпугивают новых пользователей
    • Транзакции могут не проходить из-за превышения gas limit
  3. Технические ограничения:
    • Некоторые операции могут стать невозможными из-за превышения лимита gas на блок
    • Неоптимизированные контракты могут не поместиться в блок
    • Сложные операции могут требовать неприемлемо высоких затрат gas

Стоимость различных операций:

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. Storage операции:
    • Оптимизация структур данных
    • Упаковка переменных
    • Минимизация записей в storage
  2. Вычислительные операции:
    • Оптимизация циклов
    • Использование более эффективных алгоритмов
    • Кэширование промежуточных результатов
  3. Внешние вызовы:
    • Минимизация количества вызовов
    • Батчинг операций
    • Оптимизация передаваемых данных

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;  // Требует отдельный слот из-за размера
}

Почему это лучше:

  • Все переменные кроме value упакованы в один слот
  • Экономия: 2 слота = 40,000 gas при деплое
  • При чтении данных мы загружаем один слот вместо трех

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. Порядок объявления переменных:

    • Группируйте переменные одного размера вместе
    • Размещайте константы в начале контракта
    • Используйте структуры для группировки связанных данных

2. Оптимизация модификаторов:

// Неоптимальный модификатор ❌
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-затрат

Hardhat Gas Reporter:

require("hardhat-gas-reporter");

module.exports = {
  gasReporter: {
    currency: 'USD',
    gasPrice: 21,
    enabled: process.env.REPORT_GAS ? true : false
  }
};

Solidity Metrics:

require("solidity-coverage");

module.exports = {
  solidity: {
    settings: {
      optimizer: {
        enabled: true,
        runs: 200
      }
    }
  }
};

Заключение

При оптимизации смарт-контрактов важно помнить:

  1. Storage оптимизация:
    • Упаковывайте переменные в структуры
    • Используйте минимально необходимые типы данных
    • Группируйте связанные данные
  2. Вычислительная оптимизация:
    • Используйте assembly для критических операций
    • Применяйте битовые операции где возможно
    • Избегайте повторных вычислений
  3. Безопасность:
    • Не жертвуйте безопасностью ради оптимизации
    • Всегда тестируйте оптимизированный код
    • Проводите аудит безопасности

Полезные ресурсы

  1. Ethereum Yellow Paper
  2. Solidity Documentation
  3. OpenZeppelin Contracts
  4. EVM Codes - справочник по opcodes
  5. Hardhat Documentation

Подпишись !!!

Спасибо за чтение ! Подпишись что бы не пропускать дальнейшие статьи!

Телеграм: https://t.me/one_eyes