Solidity: Путешествие в сердце оптимизации

Приветствую, кодеры Solidity!

Если вы здесь, то или у вас есть смарт-контракт, который готов к «похудению», или вы просто пытаетесь нарастить свои мышцы в области оптимизации Solidity. Как бы то ни было, сегодня я предлагаю вам навес золота в виде 27 проверенных методов оптимизации. Это ваш новый тренажерный зал для мозга! Всегда приятно иметь на руках свежий чек-лист перед запуском нового проекта.

Прежде всего, позвольте мне представиться. Я Арарат, кодирую на Solidity с 2017 года, принял участие в свыше 100 блокчейн-проектах и не раз был в топ-27 разработчиков Solidity на Upwork. Итак, давайте начнем!

1. Погружайся глубоко в изучение Solidity

Solidity — это относительно простой язык программирования. В отличие от таких языков как C/C++, Java, Python, где для достижения уровня эксперта может потребоваться более 30 лет, в случае с Solidity вы можете достичь этого уровня за пару лет. Настоятельно рекомендую полностью изучить официальную документацию, и после поэкспериментировать с интерактивный справочником по опкодам на сайте evm.codes.

Вполне верно, важно осознать суть того, с чем мы работаем. Эту мысль ярко иллюстрирует известное высказывание Авраама Линкольна:

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

2. Изучайте алгоритмы

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

5b585403b4f97732b262bb088cf8033b.png

Как вы можете наиболее эффективно и экономно обработать такую задачу на Solidity? Здесь знание алгоритмов придет на помощь, позволяя вам выбрать наиболее подходящий метод сортировки.

e076f7ca3a7f86f344f69e4927a3fb1c.png

В контексте данного примера, одним из возможных решений может быть применение алгоритма сортировки «Merge». Если вам не знаком этот алгоритм, вы можете ознакомиться с ним и с другими алгоритмами по ссылке.

3. Организуйте код с учетом приоритетов проверки

Приоритетная проверка — это стратегия, применяемая при использовании операций || или &&. Данный подход подразумевает выполнение наименее затратной операции в первую очередь, что позволяет пропустить более ресурсоемкую операцию, если результат первой оказывается истинным (true).

4b8475d2709d6a87a2a0b4f2eff43706.png

Аналогичный принцип применим и к использованию конструкции switch։

9d4d987cdffced849d3a3e9ca89e429b.png

4. Расположите переменные рационально

Хранилище Ethereum разделено на слоты размером 32 байта, и запись значений в эти слоты может быть дорогой процедурой (до 20 000 газа при использовании «холодной» записи).

Рассмотрим ситуацию, когда у вас есть 3 переменные.

1102354b0c06650224bf61e1bc363400.png

Если вы правильно распределите эти переменные по трём слотам, то сможете экономить один слот (например, переменные a и c могут быть размещены в одном слоте).

В результате, при развертывании используется на один слот меньше, что позволяет экономить 20 000 газа.

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

5. Примените public и external функции с пониманием

Ваши функции могут быть обозначены как public или external, в зависимости от того, как вы планируете их использовать. Обе функции могут быть вызваны извне вашего контракта (например, с помощью кошелька MetaMask). Однако в отличие от external функций, public функции также могут быть вызваны другими функциями внутри вашего контракта.

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

0c537c9a49a2ef047000cd9aba5215c5.png

6. Используйте require для проверок  входных данных и внешних вызовов вместо assert

Assert следует использовать для проверки условий, которые никогда не должны быть ложными в корректно работающем контракте. Использование assert подразумевает, что в случае сбоя в работе контракта все потраченные на его выполнение средства (газ) не возвращаются, что служит защитой от ошибок в коде. Однако, для проверки входных данных и внешних вызовов контракта рекомендуется использовать конструкцию require, поскольку при её использовании в случае неудачи потраченный газ возвращается.

7. Избегайте инициализации переменных значениями по умолчанию

В Solidity, если переменная не установлена или инициализирована, она действительно имеет значение по умолчанию — это может быть 0 для чисел, false для булевых значений и 0x0 для адресов.

aafbcba3172239ced09ba9fff3f08cb2.png

8. Используйте компактные сообщении revert и require

Добавление строки причины ошибки к оператору require является часто используемым подходом, однако следует помнить, что эти строки занимают пространство в развернутом байткоде. Из-за системы хранения данных Ethereum, любая строка причины ошибки будет занимать кратное число 32 байт. Поэтому, если ваша строка не соответствует длине 32 байта, она будет использовать больше пространства. Чтобы оптимизировать использование пространства, рекомендуется использовать короткие и ясные строки ошибок, которые соответствуют требованию в 32 байта.

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

546cb6528dc55e90e22540ce0c17bb7b.png

9. Удалите мертвый код

Иногда мы оставляем в коде ненужные части, которые никак не влияют на функциональность контракта:

def78b195d114675b4b0d0ede202ae66.png

Бесполезные или «мертвые» строки кода не оказывают влияние на поведение смарт-контракта. Однако, несмотря на это, они всё равно занимают место в байткоде и увеличивают затраты на газ при развертывании и взаимодействии с контрактом.

10. Используйте выражения (a, b) = (b, a) для обмена значений двух переменных

0b97f47a972b89a9f1c30d3271b29fa1.png

11. Используйте операций пакетирования

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

c9e2c184f8b0b9556030389433570ce7.png

12. Удалите хэш метаданных

В байт-коде смарт-контракта Solidity встроен специфический «хэш» — Swarm-хэш метаданных контракта, включающий ABI, исходный код и информацию о компиляторе.

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

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

Если вам прям очень надо выжать все соки от контракта, рекомендую ознакомится с этим решением тут.

13. Использование операции сдвига влево вместо умножения для увеличения производительности

Сдвиг двоичных данных n раз влево действительно приводит к умножению данных на 2^n. Однако важно заметить, что Solidity автоматически обрабатывает ситуации переполнения (overflow) для всех математических операций, включая операторы сдвига. Тем не менее, следует всегда проявлять осмотрительность при использовании этих операторов, особенно если используемые данные могут превышать размеры типа, с которым они сравниваются.

986b1cc918d01fd3158a6dee0d3e6830.png

  1. Cold access и warm access

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

5fcd53de4e123f5522a4bbc32de05b41.png

15. Используйте ++i вместо i++

На первый взгляд, эта оптимизация может показаться неочевидной, но в контексте Solidity и Ethereum это важно. Дело в том, что i++ создает временную переменную, чтобы хранить старое значение i, что требует дополнительной операции записи в память. Это приводит к увеличению затрат на газ. В свою очередь, ++i просто увеличивает значение, не создавая временной переменной, что экономит газ.

dbfc9ba6d778c9076bb24683aab91ef2.png

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

16. Используйте uint256 вместо uintXYZ

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

230a078933f3352a74fb33226e426265.png

17. Используйте команду selfdestruct() для компенсации газа

Использование функции selfdestruct снижает стоимость транзакции на 24 000 газа, но не более половины стоимости газа транзакции. Это означает, что использование функции selfdestruct имеет смысл только в редких случаях, когда нужно выполнять сложные транзакции и необходимо увеличить запас газа.

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

18. Оптимизируйте газ, убирая излишние переменные

Ethereum предоставляет возврат газа при удалении переменных. Это стимул для экономии места в блокчейне, который мы используем для снижения стоимости газа наших транзакций. Удаление переменной возвращает 15 000 газа, но не более половины стоимости газа транзакции. Таким образом, удаление переменных может помочь сэкономить газ, но только в рамках текущей транзакции.

4add9e0cca8bb227e51c86f577b0a28e.png

19. Совершайте меньше внешних вызовов

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

20. Используйте статические типы фиксированного размера

Статические типы фиксированного размера (например, bool, uint256, bytes5) более эффективны с точки зрения газа, чем динамические типы переменного размера (например, string или bytes).

bf40f1e46fe9fff1122949dcd84670c0.png

21. Используйте блок unchecked при выполнение арифметических операций

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

Однако, если вы хотите использовать предыдущее поведение, вы можете использовать блок unchecked {...}, чтобы отключить проверки на переполнение и недополнение. Это может иметь смысл в тех случаях, когда вы выполняете много арифметических операций в одном вызове функции (например, в циклах for) и хотите сэкономить на газе.

9940164feacf7cbaf9098bec8caabb78.png

22. Храните данные в памяти memory вместо storage

Выбор идеального места для размещения данных действительно очень важен. В Solidity есть три места для хранения данных: storage, memory и calldata.

storage — переменная хранится на блокчейне. Это переменная с постоянным состоянием. Для ее определения и изменения требуется газ.

memory — временная переменная, объявленная внутри функции. Не требует газа для объявления, но для изменения переменной памяти требуется газ (меньше, чем для хранения).

calldata — как память, но неизменяемая и доступна только как аргумент внешних функций¹.

Также важно отметить, что если не указано местоположение данных, то по умолчанию это storage.

23. Используйте calldata вместо memoryдля параметров функции

9dea1da270e6f13889ca06e9e5e8fc24.png

В приведенном выше в 1-ом примере динамический массив arr имеет место хранения memory. Когда функция вызывается извне, значения массива хранятся в calldata и копируются в память во время декодирования ABI (с помощью опкодов calldataload и mstore). А во время цикла for,  arr[i] получает доступ к значению в памяти с помощью mload. Однако — это неэффективно. Чтобы улучшить эффективность, можно использовать calldataкак на 2-ом примере.

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

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

f6f6566c34ddf86c8216710971323da0.png

25. Используйте индексированные событие

Вы можете отметить каждое событие как индексированное, как показано ниже:

7997511cf5f67d44fb95f6d28613bb81.png

Индексированное событие так же позволяет легче искать события.

26. Помните, mapping дешевлеarray

Mapping обычно менее дороги, чем массивы, но вы не можете выполнять над ними итерации. получить длину (.length), добавить элемент в конце (.push()) или удалить (.pop())

7cdae01c504ee0f6f133305cce816f4e.png

27. Используйте Solidity Gas Optimizer

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

Для настройки оптимизатора Solidity в Hardhat вы можете использовать следующий конфигурационный файл:

0a1c4be0b3b5e76ec7c0d0609b633b5e.png

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

Итак, мы рассмотрели 27 проверенных методов оптимизации. Еще больше про Solidity я рассказываю в рамках нового курса Solidity Developer. 13 июля я проведу полностью бесплатный урок на платформе OTUS. Поговорим про введение в смарт-контракты, а именно погрузимся в увлекательную историю смарт-контрактов, иллюстрируя их зарождение и развитие до сегодняшнего дня. Рассмотрим области применения смарт-контрактов, представив реальные примеры их использования в различных сферах, перейдем от теории к практике, создав и задеплоив наш первый смарт-контракт с помощью онлайн-инструмента Remix IDE. Завершим урок обсуждением известных случаев взлома смарт-контрактов, рассмотрим основные уязвимости и способы их устранения. Жду всех.

Регистрируйтесь и приходите. Будет много интересного

© Habrahabr.ru