Это третья, финальная часть моего цикла про 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):

Контейнер в виде массива bytes для хранения опций
Контейнер в виде массива bytes для хранения опций

После этого формируются сами опции. Например, в 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 = 1

    • OPTION_TYPE_NATIVE_DROP = 2

    • OPTION_TYPE_LZCOMPOSE = 3

    • OPTION_TYPE_ORDERED_EXECUTION = 4

    • OPTION_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 |
Расположение данных в контейнере TYPE_3
Расположение данных в контейнере TYPE_3

Особенности задания опций

Другие опции можно найти в 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 для офчейн мониторинга транзакции и другое. Поэтому данная серия статей является только точкой старта, чтобы хотя бы немного снизить порог входа в работу с протоколом.

Ссылки

Комментарии (1)


  1. MAXH0
    07.06.2025 11:36

    Вопрос: это все требует определённой криптовалюты для работы?