Плата за газ в основной сети Ethereum всегда была серьезной проблемой, особенно в периоды загруженности сети. В пиковые моменты пользователи часто вынуждены платить очень высокие сборы за транзакции. Поэтому оптимизация платы за газ на этапе разработки смарт-контрактов особенно важна. Оптимизация потребления газа не только эффективно снижает затраты на транзакции, но и повышает эффективность транзакций, обеспечивая пользователям более экономичный и эффективный опыт использования блокчейна.

В данной статье будет изложен механизм платы за газ Ethereum Virtual Machine (EVM), основные концепции оптимизации газа и лучшие практики оптимизации платы за газ при разработке смарт-контрактов. Надеемся, что эта информация может вдохновить разработчиков и предоставить им полезную помощь, а также помочь обычным пользователям лучше понять, как работает плата за газ в EVM и совместно справляться с вызовами экосистемы блокчейна.

Введение в механизм платы за газ EVM

В совместимых с EVM сетях «газ» — это единица, используемая для измерения вычислительных ресурсов, необходимых для выполнения конкретной операции.

На нижеприведённом графике показана структурная компоновка EVM. В графике потребление газа делится на три части: выполнение операций, внешние вызовы сообщений и чтение/запись памяти и хранилища.

Источник: официальный сайт Ethereum[1]

Поскольку выполнение каждой транзакции требует вычислительных ресурсов, взимается определенная плата для предотвращения бесконечных циклов и атак отказа в обслуживании (DoS). Плата, необходимая для завершения транзакции, называется «плата за газ».

С момента вступления в силу EIP-1559 (Лондонское хард-форк), плата за газ рассчитывается по следующей формуле:

Плата за газ = единицы газа, использованные * (базовая плата + приоритетная плата)

Базовая плата будет уничтожена, а приоритетная плата будет использоваться как стимул, побуждающий валидаторов добавлять транзакции в блокчейн. Установив более высокую приоритетную плату при отправке транзакции, можно повысить вероятность её включения в следующий блок. Это похоже на «чаевые», которые пользователь платит валидатору.

1. Понять оптимизацию газа в EVM

Когда смарт-контракты компилируются с помощью Solidity, контракт преобразуется в ряд «операционных кодов», т.е. opcodes.

Каждая операция с кодом (например, создание контракта, выполнение вызова сообщения, доступ к хранилищу аккаунтов и выполнение операций на виртуальной машине) имеет признанную стоимость потребления газа, эти затраты зафиксированы в жёлтой книге Ethereum[2].

После нескольких изменений EIP некоторые из операций с кодом газа были скорректированы и могут отличаться от данных в жёлтой книге. Для получения актуальной информации о стоимости операций смотрите здесь[3].

2. Основные концепции оптимизации газа

Основная идея оптимизации газа заключается в том, чтобы приоритетно выбирать операции с высокой стоимостью в эффективных операциях на блокчейне EVM, избегая операций с дорогой стоимостью газа.

В EVM следующие операции имеют более низкую стоимость:

  • Чтение и запись переменных в памяти

  • Чтение констант и неизменяемых переменных

  • Чтение и запись локальных переменных

  • Чтение переменных calldata, таких как массивы calldata и структуры

  • Вызовы внутренних функций

К дорогостоящим операциям относятся:

  • Чтение и запись состояния переменных в хранилище контракта

  • Внешние вызовы функции

  • Циклические операции

Лучшие практики оптимизации платы за газ EVM

Основываясь на вышеизложенных основных концепциях, мы подготовили список лучших практик оптимизации платы за газ для сообщества разработчиков. Следуя этим практикам, разработчики могут снизить потребление газа смарт-контрактов, уменьшить затраты на транзакции и создать более эффективные и удобные приложения.

1. Стараться минимизировать использование хранилища

В Solidity хранилище является ограниченным ресурсом, его потребление газа намного выше, чем у памяти. Каждый раз, когда смарт-контракт читает или записывает данные в хранилище, возникают высокие затраты на газ.

Согласно определениям из жёлтой книги Ethereum, стоимость операций хранения более чем в 100 раз выше, чем стоимость операций с памятью. Например, инструкции OPcodesmload и mstore потребляют всего 3 единицы газа, тогда как операции с хранилищем, такие как sload и sstore, даже в самых идеальных условиях, стоят как минимум 100 единиц.

Методы ограничения использования хранилища включают:

  • Хранение непостоянных данных в памяти

  • Сокращение количества изменений в хранилище: сохраняя промежуточные результаты в памяти, можно после завершения всех вычислений распределить результаты переменным в хранилище.

2. Упаковка переменных

Количество слотов хранения, используемых в смарт-контракте, и способ, которым разработчики представляют данные, значительно влияют на потребление газа.

Компилятор Solidity упаковывает последовательные переменные хранения в процессе компиляции и использует 32-байтовый слот хранения как базовую единицу хранения переменных. Упаковка переменных означает рациональную организацию переменных, чтобы несколько переменных могли поместиться в один слот хранения.

Слева представлена менее эффективная реализация, которая потребляет 3 слота хранения; справа более эффективная реализация.

Посредством этой настройки разработчики могут сэкономить 20 000 единиц газа (хранение неиспользуемого слота требует 20 000 газа), но теперь нужно всего два слота для хранения.

Поскольку каждый слот хранения потребляет газ, упаковка переменных оптимизирует использование газа, сокращая количество необходимых слотов хранения.

3. Оптимизировать типы данных

Переменная может быть представлена несколькими типами данных, но стоимость операций для разных типов данных также различна. Выбор подходящего типа данных помогает оптимизировать использование газа.

Например, в Solidity целые числа могут быть разделены на разные размеры: uint8, uint16, uint32 и т.д. Поскольку EVM выполняет операции по 256 бит, использование uint8 означает, что EVM сначала должен преобразовать его в uint256, что дополнительно потребляет газ.

Мы можем сравнить стоимость газа для uint8 и uint256 по коду на графике. Функция UseUint() потребляет 120 382 единиц газа, в то время как функция UseUInt8() потребляет 166 111 единиц газа.

С точки зрения затрат, использование uint256 дешевле, чем uint8. Однако, если использовать предложенную ранее упаковку переменных, то ситуация меняется. Если разработчик сможет упаковать четыре переменные uint8 в один слот хранения, общая стоимость их итерации будет ниже, чем для четырех переменных uint256. Таким образом, смарт-контракт сможет читать и записывать один слот хранения и за одну операцию поместить четыре переменные uint8 в память / хранилище.

4. Использовать переменные фиксированного размера вместо динамических переменных

Если данные могут быть ограничены 32 байтами, рекомендуется использовать тип данных bytes32 вместо bytes или strings. Как правило, переменные фиксированного размера потребляют меньше газа, чем переменные переменного размера. Если длину байтов можно ограничить, старайтесь выбирать минимальную длину от bytes1 до bytes32.

5. Отображения и массивы

Списки данных в Solidity могут быть представлены двумя типами данных: массивами (Arrays) и отображениями (Mappings), но их синтаксис и структура совершенно разные.

Отображения чаще всего более эффективны и дешевле, чем массивы, но массивы имеют возможность итерации и поддерживают упаковку типов данных. Поэтому при управлении списками данных рекомендуется использовать отображения, если нет необходимости в итерации или если можно оптимизировать потребление газа через упаковку типов данных.

6. Использовать calldata вместо памяти

Переменные, объявленные в параметрах функции, могут храниться в calldata или памяти. Основное различие между ними заключается в том, что память может быть изменена функцией, тогда как calldata является неизменяемым.

Помните этот принцип: если параметры функции являются только для чтения, следует использовать calldata вместо памяти. Это позволит избежать ненужных операций копирования из calldata функции в память.

Пример 1: использование памяти

При использовании ключевого слова memory значения массива копируются из закодированного calldata в память в процессе декодирования ABI. Стоимость выполнения этого блока кода составляет 3694 единицы газа.

Пример 2: использование calldata

При прямом чтении значений из calldata пропускаются промежуточные операции с памятью. Этот способ оптимизации снижает затраты на выполнение до всего лишь 2413 единиц газа, при этом эффективность газа увеличивается на 35%.

7. Использовать ключевые слова Constant/Immutable, когда это возможно

Переменные Constant/Immutable не хранятся в хранилище контракта. Эти переменные вычисляются во время компиляции и хранятся в байт-коде контракта. Поэтому, в отличие от хранилища, их стоимость доступа значительно ниже, и рекомендуется использовать ключевые слова Constant или Immutable, когда это возможно.

8. Использовать Unchecked, когда можно избежать переполнения / недополнения

Когда разработчики могут убедиться, что арифметические операции не приведут к переполнению или недополнению, можно использовать ключевое слово unchecked, введенное в Solidity v0.8.0, чтобы избежать избыточной проверки на переполнение или недополнение, тем самым экономя плату за газ.

На нижеприведённом графике, ограниченном условиями i

Кроме того, начиная с версии 0.8.0 и выше, компиляторы больше не требуют использования библиотеки SafeMath, так как сам компилятор уже встроил функции защиты от переполнения и недополнения.

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

Код модификатора встраивается в измененные функции, и каждый раз, когда используется модификатор, его код копируется. Это увеличивает размер байт-кода и повышает потребление газа. Вот один из способов оптимизации затрат на газ для модификаторов:

Оптимизировано до:

Оптимизировано:

В данном примере, изменив логику на внутреннюю функцию _checkOwner(), можно будет повторно использовать эту внутреннюю функцию в модификаторе, что уменьшает размер байт-кода и снижает затраты на газ.

10. Оптимизация короткого замыкания

Для операторов || и && логические операции происходят с коротким замыканием, что означает, что если первое условие уже может определить результат логического выражения, то второе условие не будет оцениваться.

Для оптимизации потребления газа следует ставить условия с низкой вычислительной стоимостью вперед, что может позволить пропустить дорогостоящие вычисления.

Дополнительные общие рекомендации

1. Удалить ненужный код

Если в контракте есть неиспользуемые функции или переменные, рекомендуется их удалить. Это самый прямой способ снизить стоимость развертывания контракта и сохранить небольшой размер контракта.

Вот несколько практических рекомендаций:

Используйте наиболее эффективные алгоритмы для вычислений. Если в контракте прямо используются результаты некоторых вычислений, их следует удалить, чтобы избежать избыточных вычислений. По сути, любые неиспользуемые вычисления должны быть удалены.

В Ethereum разработчики могут получать вознаграждение за газ, освобождая пространство в хранилище. Если переменная больше не нужна, следует использовать ключевое слово delete для ее удаления или задать ей значение по умолчанию.

Оптимизация циклов: избегайте дорогостоящих циклических операций, старайтесь объединять циклы и выносить повторяющиеся вычисления за пределы тела цикла.

2. Использовать предкомпилированные контракты

Предкомпилированные контракты предоставляют сложные библиотечные функции, такие как криптографические и хеширующие операции. Поскольку код не выполняется на EVM, а выполняется на локальных узлах клиента, требуется меньше газа. Использование предкомпилированных контрактов может сэкономить газ, уменьшая вычислительную нагрузку, необходимую для выполнения смарт-контрактов.

Примеры предкомпилированных контрактов включают алгоритм цифровой подписи на основе эллиптической кривой (ECDSA) и хеш-алгоритм SHA2-256. Используя эти предкомпилированные контракты в смарт-контрактах, разработчики могут снизить затраты на газ и повысить эффективность работы приложения.

Полный список предкомпилированных контрактов, поддерживаемых сетью Ethereum, можно найти здесь[4].

3. Использовать код встроенного ассемблера

Встраиваемая ассемблерная кодировка (in-line assembly) позволяет разработчикам писать низкоуровневый, но эффективный код, который может быть выполнен непосредственно EVM, без использования дорогих операций Solidity. Встраиваемая ассемблерная кодировка также позволяет более точно контролировать использование памяти и хранилища, что дополнительно снижает плату за газ. Кроме того, встраиваемая ассемблерная кодировка может выполнять некоторые сложные операции, которые трудно реализовать только с помощью Solidity, предоставляя больше гибкости для оптимизации потребления газа.

Вот пример кода, который экономит газ, используя встраиваемую ассемблерную кодировку:

Из приведённой выше графики видно, что по сравнению со стандартным случаем, второй случай с использованием встроенной ассемблерной кодировки имеет более высокую эффективность газа.

Тем не менее, использование встроенной ассемблерной кодировки может быть рискованным и подверженным ошибкам. Поэтому следует использовать его с осторожностью и ограничить его использование опытными разработчиками.

4. Использовать решения второго уровня

Использование решений второго уровня может уменьшить объем данных, которые необходимо хранить и обрабатывать в основной сети Ethereum.

Решения второго уровня, такие как роллапы, сайдчейны и каналы состояния, могут разгружать обработку транзакций с основной цепи Ethereum, обеспечивая более быстрые и дешевые транзакции.

Объединяя множество транзакций в одну, эти решения уменьшают количество транзакций в блокчейне, тем самым снижая плату за газ. Использование решений второго уровня также может повысить масштабируемость Ethereum, позволяя большему количеству пользователей и приложений участвовать в сети, не создавая перегрузки сети.

5. Использовать инструменты и библиотеки оптимизации

Существует множество инструментов оптимизации, таких как оптимизатор solc, оптимизатор сборок Truffle и компилятор Solidity Remix.

Эти инструменты могут помочь минимизировать размер байт-кода, удалить ненужный код и уменьшить количество операций, необходимых для выполнения смарт-контрактов. В сочетании с другими библиотеками оптимизации газа, такими как «solmate», разработчики могут эффективно снижать затраты на газ и повышать эффективность смарт-контрактов.

Заключение

Оптимизация потребления газа является важным шагом для разработчиков, так как она позволяет минимизировать затраты на транзакции и повысить эффективность смарт-контрактов в совместимых с EVM сетях. Приоритетное выполнение затратных операций, сокращение использования хранилища, использование встроенной ассемблерной кодировки и соблюдение других лучших практик, обсуждаемых в данной статье, позволяет разработчикам эффективно снижать потребление газа контрактов.

Тем не менее, необходимо отметить, что в процессе оптимизации разработчики должны действовать осторожно, чтобы не вводить уязвимости безопасности. Никогда не следует жертвовать присущей безопасности смарт-контрактов в процессе оптимизации кода и снижения потребления газа.

[1]: https://ethereum.org/en/developers/docs/gas/

[2]: https://ethereum.github.io/yellowpaper/paper.pdf

[3]: https://www.evm.codes/

[4]: https://www.evm.codes/precompiled