Краткие ABI для оптимизации calldata
Введение
В этой статье вы узнаете об оптимистических ролл-апах, стоимости транзакций на них и о том, как эта иная структура затрат требует от нас оптимизации для других целей, чем в основной сети Ethereum. Вы также узнаете, как реализовать эту оптимизацию.
Полное раскрытие информации
Я штатный сотрудник Optimismopens in a new tab, поэтому примеры в этой статье будут работать на Optimism. Однако описанная здесь техника должна работать так же хорошо и для других ролл-апов.
Терминология
При обсуждении ролл-апов термин «уровень 1» (L1) используется для Mainnet, производственной сети Ethereum. Термин «уровень 2» (L2) используется для ролл-апа или любой другой системы, которая полагается на L1 для обеспечения безопасности, но выполняет большую часть своей обработки вне сети.
Как мы можем еще больше снизить стоимость транзакций на L2?
Оптимистические ролл-апы должны сохранять запись каждой исторической транзакции, чтобы любой мог просмотреть их и проверить правильность текущего состояния. Самый дешевый способ передать данные в основную сеть Ethereum — это записать их как calldata. Это решение было выбрано как Optimismopens in a new tab, так и Arbitrumopens in a new tab.
Стоимость транзакций на L2
Стоимость транзакций на L2 состоит из двух компонентов:
- Обработка на L2, которая обычно чрезвычайно дешева
- Хранилище на 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 разделяется следующим образом:
| Раздел | Длина | Байты | Потраченные впустую байты | Потраченный впустую газ | Необходимые байты | Необходимый газ |
|---|---|---|---|---|---|---|
| Селектор функции | 4 | 0-3 | 3 | 48 | 1 | 16 |
| Нули | 12 | 4-15 | 12 | 48 | 0 | 0 |
| Адрес назначения | 20 | 16-35 | 0 | 0 | 20 | 320 |
| Сумма | 32 | 36-67 | 17 | 64 | 15 | 240 |
| Итого | 68 | 160 | 576 |
Пояснение:
- Селектор функции: в контракте менее 256 функций, поэтому мы можем различать их по одному байту. Эти байты обычно ненулевые, и поэтому стоят шестнадцать газа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 } // функция faucetCalldataInterpreter.sol
Это контракт, который транзакции должны вызывать с более короткими calldataopens in a new tab. Давайте рассмотрим его построчно.
1//SPDX-License-Identifier: Unlicense2pragma solidity ^0.8.0;345import { OrisUselessToken } from "./Token.sol";Нам нужна функция токена, чтобы знать, как ее вызывать.
1contract CalldataInterpreter {23 OrisUselessToken public immutable token;Адрес токена, для которого мы являемся прокси.
12 /**3 * @dev Укажите адрес токена4 * @param tokenAddr_ адрес контракта ERC-205 */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;23 require(length < 0x21,4 "предел длины calldataVal — 32 байта");56 require(length + startByte <= msg.data.length,7 "calldataVal пытается прочитать за пределами calldatasize");Мы собираемся загрузить в память одно 32-байтовое (256-битное) слово и удалить байты, которые не являются частью нужного нам поля. Этот алгоритм не работает для значений длиннее 32 байт, и, конечно же, мы не можем читать за концом calldata. На L1 может быть необходимо пропустить эти тесты для экономии газа, но на L2 газ чрезвычайно дешев, что позволяет проводить любые проверки на вменяемость (sanity check), какие только можно придумать.
1 assembly {2 _retVal := calldataload(startByte)3 }Мы могли бы скопировать данные из вызова в fallback() (см. ниже), но проще использовать Yulopens in a new tab, язык ассемблера EVM.
Здесь мы используем опкод CALLDATALOADopens in a new tab, чтобы считать байты с startByte до startByte+31 в стек.
В общем, синтаксис опкода в Yul следующий: <имя опкода>(<первое значение стека, если есть>,<второе значение стека, если есть>...).
12 _retVal = _retVal >> (256-length*8);Только самые старшие length байт являются частью поля, поэтому мы делаем сдвиг вправоopens in a new tab, чтобы избавиться от других значений.
Это дает дополнительное преимущество, перемещая значение в правую часть поля, так что это само значение, а не значение, умноженное на 256что-то.
12 return _retVal;3 }456 fallback() external {Когда вызов контракта Solidity не соответствует ни одной из сигнатур функций, он вызывает функцию fallback()opens in a new tab (если она есть).
В случае с CalldataInterpreter любой вызов попадает сюда, потому что других external или public функций нет.
1 uint _func;23 _func = calldataVal(0, 1);Считываем первый байт calldata, который сообщает нам функцию. Есть две причины, по которым эта функция может быть недоступна:
- Функции
pureилиviewне изменяют состояние и не требуют газа (при вызове вне сети). Нет смысла пытаться снизить их стоимость в газе. - Функции, которые полагаются на
msg.senderopens in a new tab. Значениемmsg.senderбудет адресCalldataInterpreter, а не вызывающего.
К сожалению, если посмотреть на спецификации ERC-20opens in a new tab, остается только одна функция — transfer.
У нас остается только две функции: transfer (потому что мы можем вызвать transferFrom) и faucet (потому что мы можем перевести токены обратно тому, кто нас вызвал).
12 // Вызов методов изменения состояния токена с использованием3 // информации из calldata45 // faucet6 if (_func == 1) {Вызов faucet(), у которого нет параметров.
1 token.faucet();2 token.transfer(msg.sender,3 token.balanceOf(address(this)));4 }После вызова token.faucet() мы получаем токены. Однако нам как прокси-контракту токены не нужны.
Они нужны EOA (внешнему аккаунту) или контракту, который нас вызвал.
Поэтому мы переводим все наши токены тому, кто нас вызвал.
1 // transfer (предполагается, что у нас есть разрешение на это)2 if (_func == 2) {Для перевода токенов требуются два параметра: адрес назначения и сумма.
1 token.transferFrom(2 msg.sender,Мы разрешаем вызывающим переводить только те токены, которыми они владеют
1 address(uint160(calldataVal(1, 20))),Адрес назначения начинается с байта №1 (байт №0 — это функция). Как адрес, он имеет длину 20 байт.
1 calldataVal(21, 2)Для этого конкретного контракта мы предполагаем, что максимальное количество токенов, которое кто-либо захочет перевести, умещается в два байта (менее 65 536).
1 );2 }В целом перевод занимает 35 байт calldata:
| Раздел | Длина | Байты |
|---|---|---|
| Селектор функции | 1 | 0 |
| Адрес назначения | 32 | 1-32 |
| Сумма | 2 | 33-34 |
1 } // fallback23} // контракт CalldataInterpretertest.js
Этот модульный тест на JavaScriptopens in a new tab показывает, как использовать этот механизм (и как проверить, что он работает правильно). Я буду исходить из того, что вы понимаете chaiopens in a new tab и ethersopens in a new tab и объясню только те части, которые относятся непосредственно к контракту.
1const { expect } = require("chai");23describe("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)910 const Cdi = await ethers.getContractFactory("CalldataInterpreter")11 const cdi = await Cdi.deploy(token.address)12 await cdi.deployed()13 console.log("Адрес CalldataInterpreter:", cdi.address)1415 const signer = await ethers.getSigner()Показать всеМы начинаем с развертывания обоих контрактов.
1 // Получаем токены для использования2 const faucetTx = {Мы не можем использовать высокоуровневые функции, которые обычно используем (например, token.faucet()) для создания транзакций, потому что мы не следуем ABI.
Вместо этого мы должны создать транзакцию самостоятельно, а затем отправить ее.
1 to: cdi.address,2 data: "0x01"Для транзакции необходимо указать два параметра:
to— адрес назначения. Это контракт-интерпретатор calldata.data— отправляемые calldata. В случае вызова faucet данные представляют собой один байт,0x01.
12 }3 await (await signer.sendTransaction(faucetTx)).wait()Мы вызываем метод sendTransaction подписантаopens 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()23 // Проверяем, что у нас на 256 токенов меньше4 expect (await token.balanceOf(signer.address)).to.equal(1000-256)56 // И что наш получатель получил их7 expect (await token.balanceOf(destAddr)).to.equal(256)8 }) // it9}) // describeПоказать всеСнижение затрат, когда вы контролируете контракт назначения
Если у вас есть контроль над контрактом назначения, вы можете создавать функции, которые обходят проверки msg.sender, поскольку они доверяют интерпретатору calldata.
Вы можете увидеть пример того, как это работает, здесь, в ветке control-contractopens in a new tab.
Если бы контракт отвечал только на внешние транзакции, мы могли бы обойтись всего одним контрактом. Однако это нарушило бы компонуемость. Гораздо лучше иметь контракт, который отвечает на обычные вызовы ERC-20, и другой контракт, который отвечает на транзакции с короткими данными вызовов.
Token.sol
В этом примере мы можем изменить Token.sol.
Это позволяет нам иметь ряд функций, которые может вызывать только прокси.
Вот новые части:
1 // Единственный адрес, которому разрешено указывать адрес CalldataInterpreter2 address owner;34 // Адрес CalldataInterpreter5 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), "Прокси уже установлен");89 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 /* Функции, которые позволяют прокси фактически проксировать для аккаунтов */23 function transferProxy(address from, address to, uint256 amount)4 public virtual onlyProxy() returns (bool)5 {6 _transfer(from, to, amount);7 return true;8 }910 function approveProxy(address from, address spender, uint256 amount)11 public virtual onlyProxy() returns (bool)12 {13 _approve(from, spender, amount);14 return true;15 }1617 function transferFromProxy(18 address spender,19 address from,20 address to,21 uint256 amount22 ) public virtual onlyProxy() returns (bool)23 {24 _spendAllowance(from, spender, amount);25 _transfer(from, to, amount);26 return true;27 }Показать всеДля того чтобы сообщение пришло непосредственно от объекта, передающего токены или утверждающего разрешение, необходимы эти три операции. Здесь у нас есть прокси-версия этих операций, которая:
- Изменяется с помощью
onlyProxy(), поэтому никто другой не может ими управлять. - Получает адрес, который обычно выглядит как
msg.sender, в качестве дополнительного параметра.
CalldataInterpreter.sol
Интерпретатор calldata почти идентичен приведенному выше, за исключением того, что прокси-функции получают параметр msg.sender и нет необходимости в разрешении для transfer.
1 // transfer (разрешение не требуется)2 if (_func == 2) {3 token.transferProxy(4 msg.sender,5 address(uint160(calldataVal(1, 20))),6 calldataVal(21, 2)7 );8 }910 // approve11 if (_func == 3) {12 token.approveProxy(13 msg.sender,14 address(uint160(calldataVal(1, 20))),15 calldataVal(21, 2)16 );17 }1819 // transferFrom20 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)23// Требуются два подписанта для проверки разрешений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// approval и transferFrom2const approveTx = {3 to: cdi.address,4 data: "0x03" + poorSigner.address.slice(2, 42) + "00FF",5}6await (await signer.sendTransaction(approveTx)).wait()78const destAddr2 = "0xE1165C689C0c3e9642cA7606F5287e708d846206"910const 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()1516// Проверяем, что комбинация approve / transferFrom была выполнена правильно17expect(await token.balanceOf(destAddr2)).to.equal(255)Показать всеПротестируйте две новые функции.
Обратите внимание, что для transferFromTx требуются два параметра адреса: тот, кто выдал разрешение, и получатель.
Заключение
И Optimismopens in a new tab, и Arbitrumopens in a new tab ищут способы уменьшить размер calldata, записываемых на L1, и, следовательно, стоимость транзакций. Однако, поскольку мы являемся поставщиками инфраструктуры, ищущими универсальные решения, наши возможности ограничены. Как разработчик децентрализованных приложений, вы обладаете знаниями, специфичными для приложения, что позволяет вам оптимизировать данные вызовов гораздо лучше, чем мы могли бы в обычном решении. Надеемся, эта статья поможет вам найти идеальное решение для ваших нужд.
Больше моих работ смотрите здесьopens in a new tab.
Последнее обновление страницы: 22 августа 2025 г.