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

Короткі ABI для оптимізації Calldata

рівень 2
Середнячок
Ori Pomerantz
1 квітня 2022 р.
13 читається за хвилину

Вступ

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

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

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

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

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

Як можна ще більше знизити вартість транзакцій L2?

Оптимістичні зведення повинні зберігати записи про кожну історичну транзакцію, щоб кожен міг їх переглянути і переконатися, що поточний стан є правильним. Найдешевший спосіб передати дані в Ethereum Mainnet — це записати їх як calldata. Це рішення обрали як Optimismopens in a new tab, так і Arbitrumopens in a new tab.

Вартість транзакцій L2

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

  1. Обробка L2, яка зазвичай є надзвичайно дешевою
  2. Сховище L1, яке прив’язане до витрат на газ у Mainnet

На момент написання цієї статті вартість газу L2 в Optimism становить 0,001 Gwei. З іншого боку, вартість газу L1 становить приблизно 40 gwei. Поточні ціни можна переглянути тутopens in a new tab.

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

ABI

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

Однак ABI було розроблено для L1, де байт calldata коштує приблизно стільки ж, скільки чотири арифметичні операції, а не для L2, де байт calldata коштує більше тисячі арифметичних операцій. Calldata розділено таким чином:

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

Пояснення:

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

Марна витрата 160 одиниць газу на L1 зазвичай незначна. Транзакція коштує щонайменше 21 000 одиниць газуopens in a new tab, тому додаткові 0,8 % не мають значення. Однак на L2 все інакше. Майже вся вартість транзакції — це запис її на L1. Окрім calldata транзакції, є 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 існує лише для тестування.

1 /**
2 * @dev Надає викликаючому 1000 токенів для гри
3 */
4 function faucet() external {
5 _mint(msg.sender, 1000);
6 } // функція faucet

CalldataInterpreter.sol

Це контракт, який транзакції повинні викликати з коротшими calldataopens in a new tab. Розгляньмо його рядок за рядком.

1//SPDX-License-Identifier: Unlicense
2pragma solidity ^0.8.0;
3
4
5import { OrisUselessToken } from "./Token.sol";

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

1contract CalldataInterpreter {
2
3 OrisUselessToken public immutable token;

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

1
2 /**
3 * @dev Вказує адресу токена
4 * @param tokenAddr_ адреса контракту ERC-20
5 */
6 constructor(
7 address tokenAddr_
8 ) {
9 token = OrisUselessToken(tokenAddr_);
10 } // конструктор
Показати все

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

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

Читання значення з calldata.

1 uint _retVal;
2
3 require(length < 0x21,
4 "ліміт довжини calldataVal — 32 байти");
5
6 require(length + startByte <= msg.data.length,
7 "calldataVal намагається читати за межами calldatasize");

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

1 assembly {
2 _retVal := calldataload(startByte)
3 }

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

Тут ми використовуємо опкод CALLDATALOADopens in a new tab, щоб прочитати байти від startByte до startByte+31 у стек. Загалом, синтаксис опкоду в Yul такий: <opcode name>(<перше значення стека, якщо є>,<друге значення стека, якщо є>...).

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

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

1
2 return _retVal;
3 }
4
5
6 fallback() external {

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

1 uint _func;
2
3 _func = calldataVal(0, 1);

Прочитайте перший байт calldata, який повідомляє нам про функцію. Існує дві причини, чому функція тут не буде доступна:

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

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

1
2 // Викликаємо методи токена, що змінюють стан,
3 // використовуючи інформацію з calldata
4
5 // faucet
6 if (_func == 1) {

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

1 token.faucet();
2 token.transfer(msg.sender,
3 token.balanceOf(address(this)));
4 }

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

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

Для передачі токенів потрібні два параметри: адреса призначення та сума.

1 token.transferFrom(
2 msg.sender,

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

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

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

1 calldataVal(21, 2)

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

1 );
2 }

Загалом, переказ займає 35 байтів calldata:

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

test.js

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

1const { expect } = require("chai");
2
3describe("CalldataInterpreter", function () {
4 it("Має дозволяти нам використовувати токени", async function () {
5 const Token = await ethers.getContractFactory("OrisUselessToken")
6 const token = await Token.deploy()
7 await token.deployed()
8 console.log("Адреса токена:", token.address)
9
10 const Cdi = await ethers.getContractFactory("CalldataInterpreter")
11 const cdi = await Cdi.deploy(token.address)
12 await cdi.deployed()
13 console.log("Адреса CalldataInterpreter:", cdi.address)
14
15 const signer = await ethers.getSigner()
Показати все

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

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

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

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

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

  1. to, адреса призначення. Це контракт-інтерпретатор calldata.
  2. data, calldata для надсилання. У випадку виклику faucet, дані — це один байт, 0x01.
1
2 }
3 await (await signer.sendTransaction(faucetTx)).wait()

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

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

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

1// Надаємо CDI дозвіл (затвердження не можна проксіювати)
2const approveTX = await token.approve(cdi.address, 10000)
3await approveTX.wait()
4expect(await token.allowance(signer.address, cdi.address)).to.equal(10000)

Надайте інтерпретатору calldata дозвіл на виконання переказів.

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

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

1 await (await signer.sendTransaction(transferTx)).wait()
2
3 // Перевіряємо, що у нас на 256 токенів менше
4 expect (await token.balanceOf(signer.address)).to.equal(1000-256)
5
6 // І що наш одержувач їх отримав
7 expect (await token.balanceOf(destAddr)).to.equal(256)
8 }) // it
9}) // describe
Показати все

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

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

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

Token.sol

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

1 // Єдина адреса, якій дозволено вказувати адресу CalldataInterpreter
2 address owner;
3
4 // Адреса CalldataInterpreter
5 address proxy = address(0);

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

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

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

1 /**
2 * @dev встановлює адресу для проксі (CalldataInterpreter).
3 * Може бути викликана лише один раз власником
4 */
5 function setProxy(address _proxy) external {
6 require(msg.sender == owner, "Може бути викликана лише власником");
7 require(proxy == address(0), "Проксі вже встановлено");
8
9 proxy = _proxy;
10 } // функція setProxy
Показати все

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

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

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

1 require(msg.sender == proxy);

По-перше, переконайтеся, що нас викликав проксі-сервер і ніхто інший. Якщо ні, revert.

1 _;
2 }

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

1 /* Функції, що дозволяють проксі фактично виконувати роль проксі для акаунтів */
2
3 function transferProxy(address from, address to, uint256 amount)
4 public virtual onlyProxy() returns (bool)
5 {
6 _transfer(from, to, amount);
7 return true;
8 }
9
10 function approveProxy(address from, address spender, uint256 amount)
11 public virtual onlyProxy() returns (bool)
12 {
13 _approve(from, spender, amount);
14 return true;
15 }
16
17 function transferFromProxy(
18 address spender,
19 address from,
20 address to,
21 uint256 amount
22 ) public virtual onlyProxy() returns (bool)
23 {
24 _spendAllowance(from, spender, amount);
25 _transfer(from, to, amount);
26 return true;
27 }
Показати все

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

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

CalldataInterpreter.sol

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

1 // переказ (дозвіл не потрібен)
2 if (_func == 2) {
3 token.transferProxy(
4 msg.sender,
5 address(uint160(calldataVal(1, 20))),
6 calldataVal(21, 2)
7 );
8 }
9
10 // approve
11 if (_func == 3) {
12 token.approveProxy(
13 msg.sender,
14 address(uint160(calldataVal(1, 20))),
15 calldataVal(21, 2)
16 );
17 }
18
19 // transferFrom
20 if (_func == 4) {
21 token.transferFromProxy(
22 msg.sender,
23 address(uint160(calldataVal( 1, 20))),
24 address(uint160(calldataVal(21, 20))),
25 calldataVal(41, 2)
26 );
27 }
Показати все

Test.js

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

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

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

1console.log("Адреса CalldataInterpreter:", cdi.address)
2
3// Потрібні два підписувачі для перевірки дозволів
4const signers = await ethers.getSigners()
5const signer = signers[0]
6const poorSigner = signers[1]

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

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

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

1// approve і transferFrom
2const approveTx = {
3 to: cdi.address,
4 data: "0x03" + poorSigner.address.slice(2, 42) + "00FF",
5}
6await (await signer.sendTransaction(approveTx)).wait()
7
8const destAddr2 = "0xE1165C689C0c3e9642cA7606F5287e708d846206"
9
10const transferFromTx = {
11 to: cdi.address,
12 data: "0x04" + signer.address.slice(2, 42) + destAddr2.slice(2, 42) + "00FF",
13}
14await (await poorSigner.sendTransaction(transferFromTx)).wait()
15
16// Перевіряємо, що комбінація approve / transferFrom була виконана правильно
17expect(await token.balanceOf(destAddr2)).to.equal(255)
Показати все

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

Висновок

І Optimismopens in a new tab, і Arbitrumopens in a new tab шукають способи зменшити розмір calldata, що записуються в L1, а отже, і вартість транзакцій. Однак, як у постачальників інфраструктури, що шукають загальні рішення, наші можливості обмежені. Як розробник dapp, ви володієте знаннями, специфічними для вашої програми, що дозволяє оптимізувати ваші calldata набагато краще, ніж ми могли б у загальному рішенні. Сподіваємося, ця стаття допоможе вам знайти ідеальне рішення для ваших потреб.

Більше моїх робіт дивіться тутopens in a new tab.

Останні оновлення сторінки: 22 серпня 2025 р.

Чи була ця інструкція корисною?