Плата за газ в основній мережі 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 пам'ять (Storage) є обмеженим ресурсом, витрати на газ для якого значно вищі, ніж для пам'яті (Memory). Кожного разу, коли смарт-контракт читає або записує дані з пам'яті, виникає значна вартість газу.

Згідно з визначенням жовтої книги 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 або memory. Основна різниця між ними полягає в тому, що пам'ять може бути змінена функцією, а calldata є незмінним.

Пам'ятайте цю принцип: якщо параметри функції є лише для читання, слід надавати перевагу calldata, а не memory. Це дозволяє уникнути непотрібних операцій копіювання з calldata функції в пам'ять.

Приклад 1: використання пам'яті

При використанні ключового слова memory значення масиву копіюються з закодованого calldata в пам'ять під час декодування ABI. Вартість виконання цього блоку коду становить 3 694 одиниць газу.

Приклад 2: використання calldata

Коли значення читаються безпосередньо з calldata, пропускаються проміжні операції пам'яті. Цей спосіб оптимізації знижує витрати на виконання до всього лише 2 413 одиниць газу, підвищуючи ефективність газу на 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.

Рішення другого рівня, такі як rollups, бокові ланцюги та канали стану, можуть знизити навантаження на основну мережу 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