
Это третья, финальная часть моего цикла про LayerZero v2. В первой части я разобрал, как развернуть простой OApp в Remix, во второй — показал, как сделать оминчейн приложение на примере OFT-токена. Теперь пришло время докрутить детали.
В этой части рассказываю, как формируются опции: какие они бывают и как они устроены, а также рассмотрим дополнительный модуль PreCrime.
Какие бывают опции и как они устроены?
Протокол LayerZero содержит множество особенностей, которые важно понимать при разработке. Одна из таких деталей — options.
Мы уже неоднократно использовали options в самой простой форме, например так:
uint128 GAS_LIMIT = 50000; // gasLimit для Executor
uint128 MSG_VALUE = 0; // msg.value для lzReceive() (wei)
bytes memory options = OptionsBuilder.newOptions().addExecutorLzReceiveOption(GAS_LIMIT, MSG_VALUE);
За формирование опций отвечает библиотека OptionsBuilder. Первое, что делает OptionsBuilder, это вызов функции newOptions.
function newOptions() internal pure returns (bytes memory) {
return abi.encodePacked(TYPE_3);
}
Контракт OFTCore наследуется от OAppOptionsType3, который проверяет, что все опции должны быть типа TYPE_3 через _assertOptionsType3.
Кроме того, OptionsBuilder::addExecutorLzReceiveOption имеет модификатор onlyType3.
Почему нельзя использовать TYPE_1 и TYPE_2? Это устаревшие контейнеры для упаковки опций из LayerZero v1. В LayerZero v2 используется только TYPE_3, так как он более гибкий и позволяет расширять функциональность.
Содержимое контейнера
Опции разных типов:
TYPE_1— содержал толькоgasLimit.TYPE_2— добавлял возможность передачи нативных токенов (native drop).TYPE_3— включает дополнительные параметры и позволяет комбинировать опции.
Контейнер TYPE_3 состоит из:
типа контейнера,
ID воркера,
размера опций,
типа опций,
самих опций.
Сначала добавляется тип контейнера (TYPE_3):

После этого формируются сами опции. Например, в addExecutorLzReceiveOption данные кодируются через библиотеку ExecutorOptions:
// OptionsBuilder library
function addExecutorLzReceiveOption(
bytes memory _options,
uint128 _gas,
uint128 _value
) internal pure onlyType3(_options) returns (bytes memory) {
bytes memory option = ExecutorOptions.encodeLzReceiveOption(_gas, _value);
return addExecutorOption(_options, ExecutorOptions.OPTION_TYPE_LZRECEIVE, option);
}
// ---------------------------------------------------------
// ExecutorOptions library
function encodeLzReceiveOption(uint128 _gas, uint128 _value) internal pure returns (bytes memory) {
return _value == 0 ? abi.encodePacked(_gas) : abi.encodePacked(_gas, _value);
}
Здесь выполняется конкатенация GAS_LIMIT и MSG_VALUE:

После этого добавляются ID воркера, размер и тип опции:
function addExecutorOption(
bytes memory _options,
uint8 _optionType,
bytes memory _option
) internal pure onlyType3(_options) returns (bytes memory) {
return abi.encodePacked(
_options,
ExecutorOptions.WORKER_ID,
_option.length.toUint16() + 1, // размер опции + 1 байт для типа
_optionType,
_option
);
}
Здесь используются:
-
Worker ID (на данный момент их всего два):
Executor (
id = 1) — обрабатывает опции черезExecutorOptions.DVN (
id = 2) — используетсяDVNOptions.
-
Option Type (пока их 5):
OPTION_TYPE_LZRECEIVE= 1OPTION_TYPE_NATIVE_DROP= 2OPTION_TYPE_LZCOMPOSE= 3OPTION_TYPE_ORDERED_EXECUTION= 4OPTION_TYPE_LZREAD= 5
Финальная структура addExecutorLzReceiveOption:
worker id = 1(Executor)option type = 1(OPTION_TYPE_LZRECEIVE)option length = 17(gasLimit = 16 байт + 1 байт type)
Если value > 0, длина option будет больше (gasLimit 16 + value 16 + type 1 = 33 байта). В нашем случае нулевой value отбрасывается. В результате кодировка опций будет такой:
0003 01 0011 01 0000000000000000000000000000c350
opt.type | work.id | ex.opt.type.length | ex.opt.type | option |
uint16 | uint8 | uint16 | uint8 | uint128 | 0 |
3 | 1 | 17 | 1 | 50000 (gasLimit)| value |

Особенности задания опций
Другие опции можно найти в OptionsBuilder.
Также можно попробовать сформировать разные виды опций в Remix.
Работа с gasLimit
Параметр gasLimit рассчитывается на основе профилирования расхода газа в сети назначения. "Стоимость" выполнения опкодов может отличаться в разных блокчейнах, поэтому важно учитывать особенности каждой сети.
У каждого блокчейна есть максимальный лимит газа (nativeCap), который можно передать в сеть назначения. Получить эту информацию можно через Executor::dstConfig(dstEid), которая возвращает структуру DstConfig:
struct DstConfig {
uint64 baseGas;
uint16 multiplierBps;
uint128 floorMarginUSD;
uint128 nativeCap;
}
baseGas— фиксированная стоимость газа для базовых операций (lzReceive, верификация через стек безопасности). Это минимальное количество газа для самого маленького сообщения.multiplierBps— множитель в базисных пунктах (1 bps = 0,01%). Используется для расчёта дополнительного газа в зависимости от размера сообщения.floorMarginUSD— минимальная плата в USD для предотвращения спама и покрытия затрат. Если рассчитанная стоимость транзакции в USD ниже этого значения, она устанавливается наfloorMarginUSD.nativeCap— максимальный лимит газа, который OApp может указать для выполнения сообщения в сети назначения.
Эти данные можно получить в проекте, выполнив команду:
npx hardhat lz:oapp:config:get:executor
Native drop
Native drop — это передача нативных токенов в сеть назначения. Например, в addExecutorLzReceiveOption второй параметр (MSG_VALUE) используется именно для этого.
Есть также отдельная опция addExecutorNativeDropOption, которая принимает amount (сумму) и receiver (адрес получателя) и не требует указания gasLimit:
function addExecutorNativeDropOption(
bytes memory _options,
uint128 _amount,
bytes32 _receiver
) internal pure onlyType3(_options) returns (bytes memory) {
bytes memory option = ExecutorOptions.encodeNativeDropOption(_amount, _receiver);
return addExecutorOption(_options, ExecutorOptions.OPTION_TYPE_NATIVE_DROP, option);
}
Примечание: Ранее в протоколе было ограничение на native drop - 0.5 токена в эквиваленте нативного токена сети (например ETH, BNB и т.д.). Более того, было предостережение, что чем больше сумма, тем больше расходы на передачу. Сейчас об этом нет упоминаний, но нужно иметь в виду что такого рода ограничения тоже могут возникнуть.
Мы с коллегами периодически пишем в нашем Telegram-канале. Иногда это просто мысли вслух, иногда какие-то наблюдения с проектной практики. Не всегда всё оформляем в статьи, иногда проще написать пост в телегу. Так что, если интересно, что у нас в работе и что обсуждаем, можете заглянуть.
PreCrime
В исходной сети есть несколько механизмов для проверки транзакции перед отправкой:
quotemsgInspectorenforcedOptions
Но в сети назначения все сложнее, так как состояние другого блокчейна неизвестно.
В базовой реализации OFT-токена остался один смарт-контракт который мы не рассматривали — OAppPreCrimeSimulator.
Этот контракт позволяет подключить PreCrime. Модуль PreCrime используется для офчейн-симуляции _lzReceive, по задумке может работать сразу со всеми сетями где развернуто приложение.
При этом вызов OAppPreCrimeSimulator::lzReceiveAndRevert всегда завершается revert, а результаты симуляции читаются из ошибки SimulationResult, куда они записываются через PreCrime::buildSimulationResult.
Это дополнительный слой безопасности, но главная особенность PreCrime — его интеграция со стеком безопасности. То есть у него есть полномочия отменять трназакции еще на этапе проверок в канале передачи данных.
Бэкенд может отслеживать транзакции и использовать PreCrime для проверки основных инвариантов протокола. Если обнаружена вредоносная транзакция, она отменяется в MessagingChannel, прежде чем попадет в Executor.
Заключение
Я надеюсь, что по итогу трех статей у вас сложится более полная картина о разработке OApp в целом и OFT в частности, а также понимание того, как правильно оценить затраты на газ и какие выбрать опции для отправки сообщения.
В протоколе LayerZero есть еще много интересных аспектов, например работа с комиссиями внутри протокола, возможность развертывания своего Executor, отправка compose сообщений, lzRead для офчейн мониторинга транзакции и другое. Поэтому данная серия статей является только точкой старта, чтобы хотя бы немного снизить порог входа в работу с протоколом.
MAXH0
Вопрос: это все требует определённой криптовалюты для работы?