
Это третья, финальная часть моего цикла про 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
В исходной сети есть несколько механизмов для проверки транзакции перед отправкой:
quote
msgInspector
enforcedOptions
Но в сети назначения все сложнее, так как состояние другого блокчейна неизвестно.
В базовой реализации OFT-токена остался один смарт-контракт который мы не рассматривали — OAppPreCrimeSimulator
.
Этот контракт позволяет подключить PreCrime
. Модуль PreCrime
используется для офчейн-симуляции _lzReceive
, по задумке может работать сразу со всеми сетями где развернуто приложение.
При этом вызов OAppPreCrimeSimulator::lzReceiveAndRevert
всегда завершается revert
, а результаты симуляции читаются из ошибки SimulationResult
, куда они записываются через PreCrime::buildSimulationResult
.
Это дополнительный слой безопасности, но главная особенность PreCrime — его интеграция со стеком безопасности. То есть у него есть полномочия отменять трназакции еще на этапе проверок в канале передачи данных.
Бэкенд может отслеживать транзакции и использовать PreCrime
для проверки основных инвариантов протокола. Если обнаружена вредоносная транзакция, она отменяется в MessagingChannel
, прежде чем попадет в Executor.
Заключение
Я надеюсь, что по итогу трех статей у вас сложится более полная картина о разработке OApp в целом и OFT в частности, а также понимание того, как правильно оценить затраты на газ и какие выбрать опции для отправки сообщения.
В протоколе LayerZero есть еще много интересных аспектов, например работа с комиссиями внутри протокола, возможность развертывания своего Executor, отправка compose
сообщений, lzRead для офчейн мониторинга транзакции и другое. Поэтому данная серия статей является только точкой старта, чтобы хотя бы немного снизить порог входа в работу с протоколом.
MAXH0
Вопрос: это все требует определённой криптовалюты для работы?