Перейти до основного вмісту

Короткі ABI для оптимізації даних виклику

рівень 2 (l2)
Середній рівень
Орі Померанц
1 квітня 2022 р.
13 хвилин на читання
Редагувати сторінку (opens in a new tab)

Вступ

У цій статті ви дізнаєтеся про optimistic-ролапи, вартість транзакцій у них і те, як ця інша структура витрат вимагає від нас оптимізації інших речей, ніж у головній мережі Ethereum. Ви також дізнаєтеся, як реалізувати цю оптимізацію.

Повне розкриття інформації

Я працюю в Optimism (opens in a new tab) на повну ставку, тому приклади в цій статті будуть виконуватися в Optimism. Однак метод, описаний тут, має так само добре працювати й для інших ролапів.

Термінологія

Під час обговорення ролапів термін «рівень 1 (l1)» використовується для Головної мережі (Mainnet), робочої мережі Етеріум. Термін «рівень 2 (l2)» використовується для ролапу або будь-якої іншої системи, яка покладається на рівень 1 (l1) щодо безпеки, але виконує більшу частину своєї обробки позамережево.

Як ми можемо ще більше знизити вартість транзакцій рівня 2 (l2)?

Optimistic-ролапи повинні зберігати запис кожної історичної транзакції, щоб будь-хто міг переглянути їх і переконатися, що поточний стан є правильним. Найдешевший спосіб передати дані в головну мережу Ethereum — записати їх як дані виклику (calldata). Це рішення обрали як Optimism (opens in a new tab), так і Arbitrum (opens in a new tab).

Вартість транзакцій рівня 2 (l2)

Вартість транзакцій рівня 2 (l2) складається з двох компонентів:

  1. Обробка на рівні 2 (l2), яка зазвичай надзвичайно дешева
  2. Зберігання на рівні 1 (l1), яке прив'язане до вартості газу в Головній мережі

На момент написання цієї статті вартість газу рівня 2 (l2) в Optimism становить 0.001 Gwei. З іншого боку, вартість газу рівня 1 (l1) становить приблизно 40 Gwei. Ви можете переглянути поточні ціни тут (opens in a new tab).

Байт даних виклику коштує або 4 газу (якщо він нульовий), або 16 газу (якщо він має будь-яке інше значення). Однією з найдорожчих операцій в EVM є запис у сховище. Максимальна вартість запису 32-байтового слова у сховище на рівні 2 (l2) становить 22100 газу. Наразі це 22.1 Gwei. Отже, якщо ми зможемо заощадити хоча б один нульовий байт даних виклику, ми зможемо записати близько 200 байтів у сховище і все одно залишитися у виграші.

ABI

Переважна більшість транзакцій звертається до контракту з акаунта, що належить зовнішньому власнику (EOA). Більшість контрактів написані на Solidity та інтерпретують своє поле даних відповідно до двійкового інтерфейсу застосунку (ABI) (opens in a new tab).

Однак ABI був розроблений для рівня 1 (l1), де байт даних виклику коштує приблизно стільки ж, скільки чотири арифметичні операції, а не для рівня 2 (l2), де байт даних виклику коштує понад тисячу арифметичних операцій. Дані виклику поділяються таким чином:

РозділДовжинаБайтиВитрачені марно байтиВитрачений марно газНеобхідні байтиНеобхідний газ
Селектор функції40-3348116
Нулі124-15124800
Адреса призначення2016-350020320
Сума3236-67176415240
Разом68160576

Пояснення:

  • Селектор функції: Контракт має менше ніж 256 функцій, тому ми можемо розрізняти їх за допомогою одного байта. Ці байти зазвичай ненульові, а тому коштують шістнадцять газу (opens in a new tab).
  • Нулі: Ці байти завжди дорівнюють нулю, оскільки для зберігання двадцятибайтової адреси не потрібне тридцятидвохбайтове слово. Байти, що містять нуль, коштують чотири газу (див. Жовту книгу (opens in a new tab), Додаток G, стор. 27, значення для Gtxdatazero).
  • Сума: Якщо ми припустимо, що в цьому контракті decimals дорівнює вісімнадцяти (стандартне значення), а максимальна кількість токенів, які ми переказуємо, становитиме 1018, ми отримаємо максимальну суму 1036. 25615 > 1036, тому п'ятнадцяти байтів достатньо.

Марна витрата 160 газу на рівні 1 (l1) зазвичай є незначною. Транзакція коштує щонайменше 21 000 газу (opens in a new tab), тому додаткові 0.8% не мають значення. Однак на рівні 2 (l2) все інакше. Майже вся вартість транзакції полягає в її записі на рівень 1 (l1). Окрім даних виклику транзакції, є 109 байтів заголовка транзакції (адреса призначення, підпис тощо). Тому загальна вартість становить 109*16+576+160=2480, і ми марно витрачаємо близько 6.5% від неї.

Зниження витрат, коли ви не контролюєте місце призначення

Припускаючи, що ви не маєте контролю над контрактом призначення, ви все одно можете використати рішення, подібне до цього (opens in a new tab). Давайте розглянемо відповідні файли.

Token.sol

Це контракт призначення (opens in a new tab). Це стандартний контракт ERC-20 з однією додатковою функцією. Ця функція faucet дозволяє будь-якому користувачеві отримати трохи токенів для використання. Це зробило б робочий контракт ERC-20 марним, але це полегшує життя, коли ERC-20 існує лише для сприяння тестуванню.

    /**
     * @dev Надає тому, хто викликає, 1000 токенів для гри
     */
    function faucet() external {
        _mint(msg.sender, 1000);
    }   // function faucet

CalldataInterpreter.sol

Це контракт, який транзакції повинні викликати з коротшими даними виклику (opens in a new tab). Давайте розглянемо його рядок за рядком.

//SPDX-License-Identifier: Unlicense
pragma solidity ^0.8.0;


import { OrisUselessToken } from "./Token.sol";

Нам потрібна функція токена, щоб знати, як її викликати.

контракт CalldataInterpreter {

    OrisUselessToken public immutable token;

Адреса токена, для якого ми є проксі-контрактом.

Адреса токена — це єдиний параметр, який нам потрібно вказати.

    function calldataVal(uint startByte, uint length)
        private pure returns (uint) {

Зчитування значення з даних виклику.

        uint _retVal;

        require(length < 0x21,
            "calldataVal length limit is 32 bytes");

        require(length + startByte <= msg.data.length,
            "calldataVal trying to read beyond calldatasize");

Ми збираємося завантажити одне 32-байтове (256-бітне) слово в пам'ять і видалити байти, які не є частиною потрібного нам поля. Цей алгоритм не працює для значень, довших за 32 байти, і, звичайно, ми не можемо читати за межами кінця даних виклику. На рівні 1 (l1) може знадобитися пропустити ці перевірки, щоб заощадити газ, але на рівні 2 (l2) газ надзвичайно дешевий, що дозволяє проводити будь-які перевірки коректності, які ми тільки можемо придумати.

        assembly {
            _retVal := calldataload(startByte)
        }

Ми могли б скопіювати дані з виклику до fallback() (див. нижче), але простіше використати Yul (opens in a new tab), мову асемблера EVM.

Тут ми використовуємо опкод CALLDATALOAD (opens in a new tab), щоб зчитати байти з startByte по startByte+31 у стек. Загалом, синтаксис опкоду в Yul такий: <opcode name>(<first stack value, if any>,<second stack value, if any>...).


        _retVal = _retVal >> (256-length*8);

Лише найбільш значущі length байтів є частиною поля, тому ми виконуємо зсув вправо (opens in a new tab), щоб позбутися інших значень. Це має додаткову перевагу: значення переміщується в праву частину поля, тому це саме значення, а не значення, помножене на 256щось.


        return _retVal;
    }


    fallback() external {

Коли виклик контракту Solidity не збігається з жодною із сигнатур функцій, він викликає функцію fallback() (opens in a new tab) (за умови, що вона існує). У випадку з CalldataInterpreter, будь-який виклик потрапляє сюди, оскільки немає інших функцій external або public.

        uint _func;

        _func = calldataVal(0, 1);

Зчитуємо перший байт даних виклику, який вказує нам на функцію. Є дві причини, чому функція може бути недоступною тут:

  1. Функції, які є pure або view, не змінюють стан і не коштують газу (при позамережевому виклику). Немає сенсу намагатися зменшити їхню вартість у газі.
  2. Функції, які покладаються на msg.sender (opens in a new tab). Значенням msg.sender буде адреса CalldataInterpreter, а не того, хто викликає.

На жаль, дивлячись на специфікації ERC-20 (opens in a new tab), це залишає лише одну функцію — transfer. Це залишає нам лише дві функції: transfer (оскільки ми можемо викликати transferFrom) та faucet (оскільки ми можемо переказати токени назад тому, хто нас викликав).


        // Викликати методи токена, що змінюють стан, використовуючи
        // інформацію з даних виклику

        // faucet
        if (_func == 1) {

Виклик faucet(), який не має параметрів.

            token.faucet();
            token.transfer(msg.sender,
                token.balanceOf(address(this)));
        }

Після виклику token.faucet() ми отримуємо токени. Однак, як проксі-контракту, нам не потрібні токени. Вони потрібні EOA (акаунту, що належить зовнішньому власнику) або контракту, який нас викликав. Тому ми переказуємо всі наші токени тому, хто нас викликав.

        // переказ (припускаємо, що у нас є дозвіл на це)
        if (_func == 2) {

Переказ токенів вимагає двох параметрів: адреси призначення та суми.

            token.transferFrom(
                msg.sender,

Ми дозволяємо тим, хто викликає, переказувати лише ті токени, якими вони володіють

                address(uint160(calldataVal(1, 20))),

Адреса призначення починається з байта №1 (байт №0 — це функція). Як адреса, вона має довжину 20 байтів.

                calldataVal(21, 2)

Для цього конкретного контракту ми припускаємо, що максимальна кількість токенів, яку будь-хто захоче переказати, вміщується у два байти (менше ніж 65536).

            );
        }

Загалом, переказ займає 35 байтів даних виклику:

РозділДовжинаБайти
Селектор функції10
Адреса призначення321-32
Сума233-34
    }   // fallback

}       // contract CalldataInterpreter

test.js

Цей модульний тест на JavaScript (opens in a new tab) показує нам, як використовувати цей механізм (і як перевірити, що він працює правильно). Я припускаю, що ви розумієте chai (opens in a new tab) та ethers (opens in a new tab), і поясню лише ті частини, які безпосередньо стосуються контракту.

Ми починаємо з розгортання обох контрактів.

    // Отримати токени для гри
    const faucetTx = {

Ми не можемо використовувати високорівневі функції, які ми зазвичай використовуємо (наприклад, token.faucet()), для створення транзакцій, оскільки ми не дотримуємося ABI. Натомість ми повинні створити транзакцію самостійно, а потім надіслати її.

      to: cdi.address,
      data: "0x01"

Є два параметри, які нам потрібно надати для транзакції:

  1. to, адреса призначення. Це контракт інтерпретатора даних виклику.
  2. data, дані виклику для надсилання. У випадку виклику крана дані складаються з одного байта — 0x01.

    }
    await (await signer.sendTransaction(faucetTx)).wait()

Ми викликаємо метод sendTransaction підписанта (opens in a new tab), оскільки ми вже вказали місце призначення (faucetTx.to) і нам потрібно, щоб транзакція була підписана.

// Перевірити, чи faucet надає токени правильно
expect(await token.balanceOf(signer.address)).to.equal(1000)

Тут ми перевіряємо баланс. Немає потреби економити газ на функціях view, тому ми просто запускаємо їх як зазвичай.

// Надати CDI дозвіл (схвалення не можуть бути проксійовані)
const approveTX = await token.approve(cdi.address, 10000)
await approveTX.wait()
expect(await token.allowance(signer.address, cdi.address)).to.equal(10000)

Надаємо інтерпретатору даних виклику дозвіл на виконання переказів.

// Переказ токенів
const destAddr = "0xf5a6ead936fb47f342bb63e676479bddf26ebe1d"
const transferTx = {
  to: cdi.address,
  data: "0x02" + destAddr.slice(2, 42) + "0100",
}

Створюємо транзакцію переказу. Перший байт — «0x02», за ним іде адреса призначення, і, нарешті, сума (0x0100, що дорівнює 256 у десятковій системі).

Зниження витрат, коли ви контролюєте контракт призначення

Якщо ви маєте контроль над контрактом призначення, ви можете створити функції, які обходять перевірки msg.sender, оскільки вони довіряють інтерпретатору даних виклику. Ви можете побачити приклад того, як це працює, тут, у гілці control-contract (opens in a new tab).

Якби контракт відповідав лише на зовнішні транзакції, ми могли б обійтися лише одним контрактом. Однак це порушило б компонованість. Набагато краще мати контракт, який відповідає на звичайні виклики ERC-20, і ще один контракт, який відповідає на транзакції з короткими даними виклику.

Token.sol

У цьому прикладі ми можемо змінити Token.sol. Це дозволяє нам мати низку функцій, які може викликати лише проксі-контракт. Ось нові частини:

    // Єдина адреса, якій дозволено вказувати адресу CalldataInterpreter
    address owner;

    // Адреса CalldataInterpreter
    address proxy = address(0);

Контракт ERC-20 повинен знати ідентифікатор авторизованого проксі-контракту. Однак ми не можемо встановити цю змінну в конструкторі, оскільки ми ще не знаємо її значення. Цей контракт створюється першим, оскільки проксі-контракт очікує адресу токена у своєму конструкторі.

    /**
     * @dev Викликає конструктор ERC-20.
     */
    constructor(
    ) ERC20("Oris useless token-2", "OUT-2") {
        owner = msg.sender;
    }

Адреса творця (називається owner) зберігається тут, оскільки це єдина адреса, якій дозволено встановлювати проксі-контракт.

Проксі-контракт має привілейований доступ, оскільки він може обходити перевірки безпеки. Щоб переконатися, що ми можемо довіряти проксі-контракту, ми дозволяємо викликати цю функцію лише owner, і лише один раз. Щойно proxy отримає реальне значення (не нуль), це значення неможливо буде змінити, тому навіть якщо власник вирішить стати зловмисником або його мнемонічна фраза буде розкрита, ми все одно будемо в безпеці.

    /**
     * @dev Деякі функції можуть бути викликані лише через проксі.
     */
    modifier onlyProxy {

Це функція modifier (opens in a new tab), вона змінює спосіб роботи інших функцій.

      require(msg.sender == proxy);

Спочатку перевіряємо, чи нас викликав проксі-контракт і ніхто інший. Якщо ні, виконується revert.

      _;
    }

Якщо так, запускаємо функцію, яку ми модифікуємо.

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

  1. Модифікована за допомогою onlyProxy(), тому нікому іншому не дозволено ними керувати.
  2. Отримує адресу, яка зазвичай була б msg.sender, як додатковий параметр.

CalldataInterpreter.sol

Інтерпретатор даних виклику майже ідентичний наведеному вище, за винятком того, що проксі-функції отримують параметр msg.sender і немає потреби в дозволі для transfer.

Test.js

Між попереднім кодом тестування та цим є кілька змін.

const Cdi = await ethers.getContractFactory("CalldataInterpreter")
const cdi = await Cdi.deploy(token.address)
await cdi.deployed()
await token.setProxy(cdi.address)

Нам потрібно вказати контракту ERC-20, якому проксі-контракту довіряти

console.log("CalldataInterpreter addr:", cdi.address)

// Потрібні два підписанти для перевірки дозволів
const signers = await ethers.getSigners()
const signer = signers[0]
const poorSigner = signers[1]

Щоб перевірити approve() та transferFrom(), нам потрібен другий підписант. Ми називаємо його poorSigner, оскільки він не отримує жодного з наших токенів (звісно, йому потрібно мати ETH).

// Переказ токенів
const destAddr = "0xf5a6ead936fb47f342bb63e676479bddf26ebe1d"
const transferTx = {
  to: cdi.address,
  data: "0x02" + destAddr.slice(2, 42) + "0100",
}
await (await signer.sendTransaction(transferTx)).wait()

Оскільки контракт ERC-20 довіряє проксі-контракту (cdi), нам не потрібен дозвіл для ретрансляції переказів.

Тестуємо дві нові функції. Зверніть увагу, що transferFromTx вимагає двох параметрів адреси: того, хто надає дозвіл, і того, хто його отримує.

Висновок

Як Optimism (opens in a new tab), так і Arbitrum (opens in a new tab) шукають способи зменшити розмір даних виклику, що записуються на рівень 1 (l1), і, відповідно, вартість транзакцій. Однак, як постачальники інфраструктури, що шукають універсальні рішення, наші можливості обмежені. Як розробник децентралізованого застосунку (dapp), ви маєте знання, специфічні для вашого застосунку, що дозволяє вам оптимізувати ваші дані виклику набагато краще, ніж ми могли б це зробити в універсальному рішенні. Сподіваємося, ця стаття допоможе вам знайти ідеальне рішення для ваших потреб.

Більше моїх робіт можна знайти тут (opens in a new tab).

Останнє оновлення сторінки: 3 квітня 2026 р.