
В первой части мы разобрали, как развернуть простой OApp в Remix IDE. Пора переходить ко второй. Здесь познакомимся с ключевыми смарт-контрактами и напишем свой OFT (Omnichain Fungible Token). Это поможет на практике разобраться, как работает LayerZero, и понять, на что стоит обращать внимание при разработке омничейн-приложений.
Обзор верхнеуровневой архитектуры протокола и whitepaper я сделал в отдельной статье (там же объясняется концепция омничейн-приложений). Здесь мы сфокусируемся на коде.
Примечание: у LayerZero хорошая документация, поэтому, чтобы не повторяться, я буду иногда отсылать читателя к ней. В этой статье рассмотрим основные и не самые очевидные моменты.
Терминология:
Исходная сеть - блокчейн, отправляющий данные в другую сеть.
Сеть назначения - блокчейн, принимающий данные из исходной сети.
OApp (Omnichain Application) - оминчейн приложение, имеющее все необходимые интерфейсы для отправки и получения сообщений.
OFT (Omnichain Fungible Token) - взаимозаменяемый омничейн токен.
EID - Endpoint ID. Endpoint - это смарт-контракт, который обрабатывает все входящие и исходящие сообщения в любой сети.
ZRO - utility-токен платформы LayerZero, а также токен голосования.
Executor - он же исполнитель, смарт-контракт который исполняет транзакцию по доставке сообщения в сети назначения.
OFT-токен
В предыдущей статье (часть 1) мы создали базовое омничейн-приложение. Минус этого приложения был в том, что оно работает в одном направлении (потому что SourceOApp
наследовался только от OAppSender
, а DestinationOApp
от OAppReceiver
). Конечно же необходимо делать такие решения универсальными, чтобы они могли и отправлять, и принимать сообщения. OApp-приложения могут содержать любую логику и обмениваться произвольными данными.
Один из самых интересных кейсов использования — OFT-токен. Протокол LayerZero уже продумал, как создать такой токен с использованием их платформы, и разработал стандарт OFT. Это ERC20-токен, который может существовать в любом количестве блокчейнов. Чтобы поддержать новый блокчейн, достаточно развернуть в нем новое приложение OApp и привязать его к остальным.
Возникает логичный вопрос: чем это отличается от обычного моста? Я уже отвечал на него в обзорной статье, но если коротко — главное отличие в универсальных интерфейсах и возможности обеспечить действительно высокий уровень безопасности передачи токенов.
Пример USDT0
Пример реального OFT-токена — USDT0. Это хорошо знакомый всем Tether USD (USDT), который переводит свой токен на OFT-рельсы. Возможно, когда вы читаете эту статью, токен USDT уже мигрирован на USDT0 во всех сетях, кроме Ethereum. На данный момент он доступен только в нескольких блокчейнах.
В случае с USDT0 использовали OFTAdapter — механизм, который блокирует/разблокирует исходный токен в базовой сети, а во всех остальных блокчейнах — минтит/сжигает. OFTAdapter необходим, если у вас уже есть обычный ERC20-токен, но вы хотите превратить его в OFT.

К сожалению, у проекта нет публичного GitHub-репозитория, но все смарт-контракты верифицированы, и код можно посмотреть в блокчейн-эксплорерах (ссылки здесь). Также есть интересные отчеты по аудиту USDT0 — рекомендую ознакомиться с ними тут. В них много полезной информации.
Что нужно для создания OFT-токена?
Самый быстрый способ развернуть OFT-токен для LayerZero — создать проект на своей машине через npm. Для этого выполняем команду:
npx create-lz-oapp@latest
Затем выбираем OFT.

После выбора пакетного менеджера мы получим готовый проект с OFT-токеном. Причем он из коробки поддерживает как Hardhat, так и Foundry, что особенно удобно. Останется только изменить нейминг, задеплоить контракты и настроить их взаимодействие в разных сетях. В проекте уже есть все необходимое для деплоя, тестирования, а также скрипты для оценки газа.
Структура OFT-токена
Базовая структура OFT выглядит так:

Но если посмотреть на OFT-токен более детально, он включает в себя чуть больше зависимостей. Для примера я написал токен MetaLampOFTv1. Читать схему снизу вверх.

Здесь можно увидеть два дополнительных смарт-контракта — OAppPreCrimeSimulator и OAppOptionsType3, о которых мы поговорим чуть позже. Также видно, что OApp наследуется от OAppSender и OAppReceiver и может как отправлять, так и получать сообщения. OAppCore отвечает за установку адресов endpoint
, delegate
и peers
.
Примечание: если вы не хотите разворачивать проект, готовый код можно посмотреть здесь. Для этого установите зависимости через pnpm install
в папке protocols/layerzero-v2/smart-contracts/contracts
.
Также можно заглянуть в репозиторий LayerZero-Labs/devtools — там есть все примеры.
Базовый функционал OFT
Так выглядит самый простой ERC20 OFT-токен:
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.22;
import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol";
import { OFT } from "@layerzerolabs/oft-evm/contracts/OFT.sol";
contract MetaLampOFTv1 is OFT {
constructor(
string memory _name,
string memory _symbol,
address _lzEndpoint,
address _delegate
) OFT(_name, _symbol, _lzEndpoint, _delegate) Ownable(_delegate) {}
}
Параметры
name
иsymbol
передаются при деплое, так как для каждой новой сети потребуется развернуть отдельный экземпляр токена (OApp)._lzEndpoint
— это адрес Endpoint для взаимодействия с инфраструктурой LayerZero. То есть для отправки и получения сообщений, а также оплаты комиссий. Для каждой сети он свой._delegate
— адрес владельца токена, который также отвечает за изменение настроек OApp.
В смарт-контракт токена можно добавить любую дополнительную логику или зависимости (например, Permit). Но все, что касается механики OFT, уже реализовано в контракте OFT
.
Основные функции, которые нас интересуют в OFT, — debit
и credit
. Они реализуют базовую механику mint/burn, но их можно переопределить в основном контракте токена.
Отправка токенов из исходной сети (send)
Главная функция для отправки токенов — OFTCore::send
. Если помните, в примере с Remix у нас уже была похожая функция, но теперь она стала сложнее:
function send(
SendParam calldata _sendParam, // Основные параметры для отправки сообщения
MessagingFee calldata _fee, // Комиссия на оплату газа и стека безопасности
address _refundAddress // Адрес возврата комиссии в исходной сети
) external payable virtual returns (
MessagingReceipt memory msgReceipt, // Основной чек по транзакции
OFTReceipt memory oftReceipt // Доп информация специфичная для OFT
) { ... }
Параметры, которые необходимо указать для отправки:
struct SendParam {
uint32 dstEid; // ID целевой сети в LayerZero (например, 30101 - Ethereum, 30343 - TON).
bytes32 to; // Адрес OApp в сети назначения.
uint256 amountLD; // Сумма токенов в локальных десятичных знаках decimals.
uint256 minAmountLD; // Минимальное сумма токенов в локальных decimals (например после списания комиссий).
bytes extraOptions; // Параметры, предоставленные вызывающей стороной (например количество газа, кот. потребуется на доставку).
bytes composeMsg; // Дополнительное сообщение (или несколько сообщений), для выполнения в отдельной транзакции (например swap токенов после доставки).
bytes oftCmd; // Кастомная команда для OFT, не используется в стандартных реализациях.
}
Так выглядит MessagingReceipt:
struct MessagingReceipt {
bytes32 guid; // GUID для ончейн и оффчейн отслеживания сообщения.
uint64 nonce; // Уникальный nonce для управления сообщением в канале.
MessagingFee fee; // Комиссия на газ и оплату стека безопасности
}
Как работает send
Если не углубляться в детали, функция send
выполняет три ключевых шага:
Вызывает
_debit
— сжигает токены или выполняет другую логику при отправке в сеть назначения (пока можно не обращать внимания на LD и SD amounts).Формирует сообщение через
_buildMsgAndOptions
— добавляет специфичные данные для OFT и настраивает параметры.Отправляет сообщение через
lzSend
— это первый вызов базовой функции OApp. Все предыдущие шаги были лишь подготовкой.lzSend
передает сообщение через Endpoint и переводит ему средства для покрытия комиссии.
Во время выполнения send
вызываются и другие вспомогательные функции. Все internal
-методы в контракте имеют модификатор virtual
, поэтому их можно переопределять в своем OFT-токене.

OFTCore::send
Я условно разделил поток выполнения на три основные ветки — так проще разобрать по шагам, как работает эта функция.
Local Decimals и Shared Decimals
Теперь разберем отдельные аспекты отправки токенов, начиная с служебных функций debitView
и removeDust
, а также таких понятий, как Local Decimals (LD) и Shared Decimals (SD). То есть посмотрим, что происходит в ветке 1.

amount
Зачем нужны LD и SD? Чтобы обеспечить максимальную совместимость между разными блокчейнами (включая не-EVM сети) и при этом не потерять точность, для передачи токенов в LayerZero используется uint64
и decimals = 6
.
Это значит, что максимальный totalSupply
может быть 18,446,744,073,709.551615.
Функцию OFTCore::sharedDecimals
можно переопределить, уменьшив количество знаков после запятой. Например, если уменьшить sharedDecimals
до 4, максимальное число возрастет до 1,844,674,407,370,955.1615, но точность снизится.
Увеличивать sharedDecimals
не рекомендуется — команда LayerZero протестировала такой формат и считает, что его точности достаточно для всех существующих блокчейнов.
Как это работает? Есть два ключевых этапа:
Удаление "пыли" через
_removeDust
— чтобы точно знать, сколько токенов будет отправлено.Конвертация между
local decimals
(используется в сети отправителя и получателя) иshared decimals
(используется только при передаче).
Для этого используется переменная decimalConversionRate
, которая устанавливается в конструкторе:
decimalConversionRate = 10 ** (_localDecimals - sharedDecimals());
Пример:
Допустим, в EVM-блокчейнах чаще всего decimals = 18
, тогда:
decimalConversionRate = 10 ** (18 - 6) = 1_000_000_000_000
Но что если мы хотим перевести 1 токен, с decimals = 18
, который имеет некоторый остатки и выглядит так 1_123_123_123_123_123_123
.
Удаление "пыли" (
_removeDust
). Функция_removeDust
округляет значение вниз, удаляя "пыль":function _removeDust(uint256 _amountLD) internal view virtual returns (uint256 amountLD) { return (_amountLD / decimalConversionRate) * decimalConversionRate; }
До:
1_123_123_123_123_123_123
(1.123123123123123123)
После:1_123_123_000_000_000_000
(1.123123000000000000)Конвертация в SD (
_toSD
). Для передачи в сеть назначения выполняется конвертация в shared decimals:function _toSD(uint256 _amountLD) internal view virtual returns (uint64 amountSD) { return uint64(_amountLD / decimalConversionRate); }
Мы обрезаем 12 знаков, получая
1_123_123
(1.123123).Обратная конвертация (
_toLD
). В сети назначения выполняется обратная конвертация:function _toLD(uint64 _amountSD) internal view virtual returns (uint256 amountLD) { return _amountSD * decimalConversionRate; }
Если в сети назначения
decimals = 18
, мы снова получим 1.123123000000000000.
Можете для примера взять ETH по текущим ценам и посчитать какие могут быть потери из-за такой точности. Я посчитал и это действительно "пыль".
Помимо removeDust
, функция debitView
выполняет дополнительную проверку на "проскальзывание", если при отправке взимаются дополнительные комиссии.
Мы разобрали всю ветку 1. С функцией ERC20::_burn
думаю все и так понятно.
Формирование сообщения и опций для его отправки
Теперь разберем ветку 2 — функцию _buildMsgAndOptions
. Ее можно логически разделить на три этапа:
Кодировка сообщения
Формирование опций
Проверка через инспектор (опционально)

Шаг 1: Кодировка выполняется с помощью библиотеки OFTMsgCodec
. Ее основная задача — корректно упаковать байты информации для передачи.
function _buildMsgAndOptions(
SendParam calldata _sendParam, // Параметры отправки
uint256 _amountLD // Количество токенов с local decimals
) internal view virtual returns (bytes memory message, bytes memory options) {
// 1. Кодировка сообщения
bool hasCompose;
(message, hasCompose) = OFTMsgCodec.encode(
_sendParam.to,
_toSD(_amountLD),
_sendParam.composeMsg
);
// 2. Формирование опций
uint16 msgType = hasCompose ? SEND_AND_CALL : SEND;
options = combineOptions(_sendParam.dstEid, msgType, _sendParam.extraOptions);
// 3. Опциональная проверка через инспектор
address inspector = msgInspector;
if (inspector != address(0)) IOAppMsgInspector(inspector).inspect(message, options);
}
Шаг 2: Формирование опций. Из чего состоят опции я расскажу в третей части. Здесь рассмотрим как они объединяются через combineOptions
.
Дело в том, что смарт-контакт OAppOptionsType3
позволяет задавать предустановленные "принудительные" опции (enforcedOptions
). Такие опции задает владелец OApp, например, если он точно знает, что в конкретном блокчейне нужно увеличить gasLimit
или добавить обязательный native drop.
Важно! native drop - это количество нативного токена, которое мы хотим передать вместе с сообщением. Но эти токены не идут на оплату комиссии за пересылку.
Для того чтобы можно было разграничить обычные сообщения и комбинированые (composed), в OFT добавлены два типа сообщений, в зависимости от типа можно устанавливать разные enforcedOptions
:
1 -
SEND
— обычная отправка сообщения (включая перевод токенов);2 -
SEND_AND_CALL
— используется дляcompose
-сообщений (отправка + вызов функции в сети назначения).
Пример
Допустим, мы отправляем сообщение, как в примере с Remix, если вы вернетесь к контракту Source
в нем заданы такие дефолтные опции:
bytes _options = OptionsBuilder.newOptions().addExecutorLzReceiveOption(50000, 0);
Это значит { gasLimit: 50000, value: 0 }
. Теперь представим, что в сети назначения необходимо удвоить лимит газа в два раза + добавить 0.5 ETH native drop. Тогда владелец OApp задает enforcedOptions
:
{ gasLimit: 100000, value: 0.5 ETH }
Финальный результат объединения:
{ gasLimit: 150000, value: 0.5 ETH }
Функция combineOptions
объединяет опции следующим образом:
function combineOptions(
uint32 _eid,
uint16 _msgType,
bytes calldata _extraOptions
) public view virtual returns (bytes memory) {
bytes memory enforced = enforcedOptions[_eid][_msgType];
if (enforced.length == 0) return _extraOptions;
if (_extraOptions.length == 0) return enforced;
if (_extraOptions.length >= 2) {
_assertOptionsType3(_extraOptions);
return bytes.concat(enforced, _extraOptions[2:]);
}
revert InvalidOptions(_extraOptions);
}
На схеме это будет выглядеть так:

Если
enforcedOptions
отсутствуют → используютсяextraOptions
.Если
extraOptions
отсутствуют → используютсяenforcedOptions
.Если заданы оба →
extraOptions
должны быть валидными, чтобы корректно объединиться.
Шаг 3: Проверка через инспектор (опционально). Если в OApp задан адрес контракта msgInspector
, то перед отправкой сообщения он проверяет параметры message
и options
.
Это позволяет программно задать дополнительные проверки перед передачей данных в другой блокчейн.
Мы разобрали ветку 2 — процесс кодировки и формирования опций для отправки.
Отправка сообщения
Наконец, добрались до третьей ветки выполнения функции send
, которая отвечает за непосредственную отправку сообщения через Endpoint.

Вызывается внутренняя функция OAppSender::_lzSend
, которая выполняет три ключевых действия:
Вызывает
payNative
, чтобы проверить, хватает лиmsg.value
для оплатыgasLimit
в сети назначения, либо переводит токены на Endpoint черезsafeTransferFrom
, если выбрана опция оплаты черезlzPayToken
. На данный момент в качестве_lzPayToken
может задаваться только токен протоколаZRO
.Проверяет, существует ли
peer
, которому отправляется сообщение (getPeerOrRevert
).Вызывает
Endpoint.send{ value: nativeFee }()
, отправляя сообщение в стек безопасности.
После этого сообщение передается в Endpoint, который отвечает за его дальнейшую обработку, а также оплату услуг DVNs и Executor.
Получение сообщения в сети назначения
Получение сообщения происходит через базовую функцию OAppReceiver::lzReceive
— это стандартная точка входа для всех входящих сообщений. Она выполняет базовые проверки перед вызовом OAppReceiver::_lzReceive
, которая уже переопределена с учетом логики токена в OFTCore
.
Выполняются две проверки:
Функцию
lzReceive
мог вызвать только Endpoint.Отправитель сообщения должен совпадать с
peer
, который был установлен для исходной сети черезsetPeer
.
После этого управление передается в OFTCore::_lzReceive
.

Функция OFTCore::_lzReceive
выполняет два простых шага:
Вызывает
_credit
, чтобы сминтить токены в сети назначения (или выполнить другую заложенную в токене логику).Проверяет, есть ли дополнительные транзакции для выполнения
Endpoint::sendCompose
, и при необходимости добавляет их в очередь.
function _lzReceive(
Origin calldata _origin,
bytes32 _guid,
bytes calldata _message,
address /*_executor*/, // @dev не используется в дефолтной реализации.
bytes calldata /*_extraData*/ // @dev не используется в дефолтной реализации.
) internal virtual override {
// Приводим адрес к EVM-формату
address toAddress = _message.sendTo().bytes32ToAddress();
// Вызываем OFT::_credit
uint256 amountReceivedLD = _credit(toAddress, _toLD(_message.amountSD()), _origin.srcEid);
// Если есть дополнительные транзакции, добавляем их в очередь Endpoint
if (_message.isComposed()) {
bytes memory composeMsg = OFTComposeMsgCodec.encode(
_origin.nonce,
_origin.srcEid,
amountReceivedLD,
_message.composeMsg()
);
endpoint.sendCompose(toAddress, _guid, 0 /* индекс compose-сообщения */, composeMsg);
}
emit OFTReceived(_guid, _origin.srcEid, toAddress, amountReceivedLD);
}
Важно! Чтобы OApp мог работать с compose
-транзакциями, он должен реализовать интерфейс IOAppComposer
. В базовой реализации этой функции нет.
Оценка gasLimit и комиссии стека безопасности
Для успешного выполнения транзакции в сети назначения необходимо правильно рассчитать два параметра:
Количество газа (
gasLimit
), необходимое для выполнения транзакции.Стоимость газа в сети назначения, выраженную в токенах исходной сети (например, если отправка идет из Ethereum в Polygon, расчет производится в POL, но оплатить нужно в ETH).
С gasLimit
не все так очевидно. Для разных блокчейнов можно либо выставлять его с запасом, либо рассчитывать эмпирически. Позже мы разберем, как проверить установленное значение перед отправкой.
Допустим, для EVM-сетей берем усредненное значение 80 000 единиц газа. Тогда опции выглядят так:
bytes memory options = OptionsBuilder.newOptions().addExecutorLzReceiveOption(80000, 0);
Далее нужно сформировать структуру SendParam
, заполнив все необходимые поля:
SendParam memory sendParam = SendParam(
40267, // EID
addressToBytes32(0x32bb35Fc246CB3979c4Df996F18366C6c753c29c), // Адрес получателя OFT-токенов в сети назначения
1e18, // amountLD
1e18, // minAmountLD
options,
"", // composeMsg
"" // oftCmd
);
Чтобы посчитать комиссию стека безопасности и Executer, вызываем OFTCore::quoteSend
:
MessagingFee memory fee = OFT.quoteSend(sendParam, false);

Шаги 1 и 2 здесь схожи с OFTCore::send
, но _debit
не вызывается. На третьем шаге выполняется вызов Endpoint::quote
, где рассчитывается комиссия на основе цен на газ в сети назначения и установленных параметров безопасности.
Расчеты, выполняемые Endpoint, можно посмотреть здесь.
Зная рассчитанную комиссию, можно отправить сообщение:
OFT.send{ value: fee.nativeFee }(sendParam, fee, refundAddress);
Пример можно посмотреть в тестах — test_send_oft
.
Лимит gasLimit
Ранее мы обсуждали enforcedOptions
. Если уже рассчитано среднее значение газа для конкретной сети, его можно задать через OAppOptionsType3::setEnforcedOptions
.
Оценка лимитов токена
Для OFT существует дополнительная функция предварительной проверки OFTCore::quoteOFT
. Она может быть настроена в зависимости от требований конкретного токена.
function quoteOFT(
SendParam calldata _sendParam
) external view virtual returns (
OFTLimit memory oftLimit, // Опциональные настраиваемые лимиты. По умолчанию от 0 до totalSupply.
OFTFeeDetail[] memory oftFeeDetails, // Комиссии токена, тоже опционально.
OFTReceipt memory oftReceipt // amountSentLD и amountReceivedLD
) {}
Как задеплоить и настроить OFT-токен
Если проект OFT-токена был создан через npx create-lz-oapp@latest
, в нем уже есть необходимые скрипты для деплоя. Достаточно создать файл .env
и настроить нужные сети в hardhat.config.ts
для деплоя и верификации контрактов в эксплорерах.
После этого можно запустить команду:
npx hardhat lz:deploy
Затем следовать инструкциям, в качестве тега указать название смарт-контракта. Подробная инструкция есть в документации или в README
проекта.

После деплоя контракты развернуты, но их еще нужно настроить и связать между собой.
Первым шагом необходимо создать конфигурацию. Для этого есть отдельный hardhat-скрипт:
npx hardhat lz:oapp:config:init --contract-name MetaLampOFTv1 --oapp-config layerzero.config.ts
В результате создается файл layerzero.config.ts
, в котором задаются стандартные параметры стека безопасности, а также указывается адрес Executor для выбранных сетей.
Следующий шаг — применение этих настроек к OApps (контрактам токенов в разных сетях) и контрактам Endpoint.
npx hardhat lz:oapp:wire --oapp-config layerzero.config.ts
Этот скрипт выполнит все необходимые транзакции в каждой сети. Поскольку их будет много, стоит убедиться, что хватает средств на оплату газа.
В процессе будут вызваны следующие функции:
OFTToken::setPeer
OFTToken::setEnforcedOptions
(если они указаны в конфигурации)Endpoint::setConfig
Endpoint::setSendLibrary
Endpoint::setReceiveLibrary
Для каждого блокчейна можно отдельно задать setEnforcedOptions
который мы обсуждали выше.
Прелесть в том, что если вы измените какие-то опции, то при следующем запуске скрипта выполнятся только те транзакции, которые нужны для установки новых опций, все остальное будет пропущено.
Подробнее про конфигурацию можно почитать в документации.
Отправка транзакции
Отправка омничейн-токенов — не самый простой процесс. Без вспомогательных скриптов обойтись сложно, поэтому я написал Foundry-скрипт SendTokens
, который позволяет пересылать токены между контрактами MetaLampOFTv1
в сетях Ethereum Sepolia и Polygon.
Перед отправкой токенов их нужно получить на баланс. Для этого в контракте есть функция claim
, которая начисляет 100 MLOFTv1. Проще всего вызвать ее через блокчейн-эксплореры соотвествующих блокчейнов (ссылки на контракты есть здесь).
Команда для отправки токенов:
pnpm send \
--rpc-url <rpc_url> \
<sender_address> \
<src_oft_oapp_address> \
<dst_recipient_address> \
<amount_LD> \
<dst_eid> \
--broadcast
Пример отправки:
pnpm send \
--rpc-url sepolia \
0x32bb35Fc246CB3979c4Df996F18366C6c753c29c \
0xcd5407ae7FA70C6ea1f77eDD7A3dde34BED187F5 \
0x32bb35Fc246CB3979c4Df996F18366C6c753c29c \
1000000000000000000 \
40267 \
--broadcast
Результат:
== Logs ==
GUID:
0x832318c92f1b0abe842f8ec5059d47aad92df8ca8de6a94b4bf8be301b689952
MessagingReceipt: nonce: 4, fee: 75768729416500
OFTReceipt: amountSentLD: 1000000000000000000, amountReceivedLD: 1000000000000000000
##### sepolia
✅ [Success] Hash: 0xb791c8aae098e5bfe449ddf58e012beebbf1ff2c3b81960adddd6abc67a7620e
После отправки можно взять хэш транзакции и проверить ее статус в LayerZeroScan. Если статус "Delivered", то токены успешно дошли до сети назначения. Можно проверить баланс в сети назначения, а также totalSupply
в обеих сетях.
Примечание: рекомендую сначала запустить команду без флага --broadcast
, чтобы посмотреть, сколько fee потребуется для транзакции. Например, при отправке в обратном направлении мне рассчитали очень высокий nativeFee
в Polygon Amoy — вероятно, из-за проблем с priceFeed
.
Что если транзакция не выполнилась?
У меня была ситуация, когда я отправил транзакцию с недостаточным gasLimit
, из-за чего она упала в сети назначения. В результате токены были сожжены в исходной сети, но не были выпущены в сети назначения, и общий totalSupply
нарушился.
Решение оказалось простым: я вызвал Endpoint::lzReceive
в сети назначения, передав аргументы застрявшей транзакции, после чего она была успешно выполнена. Такую транзакцию может выполнить любой, кто оплатит газ, так как она уже прошла все проверки, и не важно, кто будет исполнителем (не обязательно смарт-контракт Executor, этом может быть обычный пользователь, который вызовет транзакцию на etherscan).
Это одно из преимуществ протокола LayerZero — возможность исправлять некоторые ошибки вручную. Однако это не означает, что он полностью защищает от всех возможных ошибок. Поэтому важно тщательно тестировать все кейсы использования вашего OFT-токена.
Средняя оценка gasLimit
В проекте, созданном через npx create-lz-oapp@latest
, есть скрипты для оценки газа (lzReceive
и lzCompose
). Они запускают форк нужной сети, прогоняют транзакции указанное количество раз и выдают средние значения.
На момент написания статьи команда запуска lzReceive
в шаблоне была ошибочной. Я исправил ее в этом репозитории.
Есть несколько нюансов:
Для запуска скрипта требуется сообщение в формате
bytes
.Если получатель в сети назначения не имеет баланса, оценка газа будет выше. Первая запись в слот смарт-контракта дороже, чем последующие перезаписи.
Результаты скрипта показались мне заниженными по сравнению с замерами в Gas Profiler от Tenderly.
Чтобы получить сообщение в bytes
, используем команду:
forge script scripts/SendTokens.s.sol \
--via-ir \
--sig "encode(address,uint256,bytes)" \
<recipient_address> \
<amount_LD> \
<compose_msg>
Пример:
forge script scripts/SendTokens.s.sol \
--via-ir \
--sig "encode(address,uint256,bytes)" \
0x4cD6778754ba04F069f8D96BCD7B37Ccae6A145d \
1000000000000000000 \
"0x"
Выходные данные:
== Return ==
_msg: bytes 0x0000000000000000000000004cd6778754ba04f069f8d96bcd7b37ccae6a145d00000000000f4240
Теперь можно запустить скрипт для оценки газа:
pnpm gas:lzReceive \
<rpcUrl> \
<endpointAddress> \
"(<srcEid>,<sender>,<dstEid>,<receiver>,[<message>],<msg.value>)<numOfRuns>"
Где:
rpcUrl
— RPC URL сети, для которой считаем среднийgasLimit
.endpointAddress
— адрес Endpoint в этой сети.srcEid
— EID исходной сети.sender
— адрес OApp в исходной сети.dstEid
— EID сети назначения.receiver
— адрес OApp в сети назначения.message
— массив сообщений в форматеbytes
.msg.value
— количество нативных токенов (в wei).numOfRuns
— количество запусков.
Пример:
pnpm gas:lzReceive \
polygonAmoy \
0x6EDCE65403992e310A62460808c4b910D972f10f \
"(40161,0x000000000000000000000000cd5407ae7fa70c6ea1f77edd7a3dde34bed187f5,40267,0x54d412fee228e13a42f38bc760faeffdfe838536,[0x0000000000000000000000004cd6778754ba04f069f8d96bcd7b37ccae6a145d00000000000f4240],0,10)"
Выходные данные:
== Logs ==
Starting gas profiling for lzReceive on dstEid: 40267
---------------------------------------------------------
Aggregated Gas Metrics Across All Payloads:
Overall Average Gas Used: 19051
Overall Minimum Gas Used: 19051
Overall Maximum Gas Used: 19051
Estimated options:
0x00030100110100000000000000000000000000004e23
---------------------------------------------------------
Finished gas profiling for lzReceive on dstEid: 40267
---------------------------------------------------------
OFTProfilerExample
Мне больше понравился скрипт OFTProfilerExample
.
Он запускается с предустановленными параметрами, но выдает результаты, приближенные к реальным. Его конфигурации можно изменять, и он легко запускается:
pnpm gas:run 10
Где 10
— число прогонов. Если скрипт не запускается, попробуйте убрать флаг --via-ir
в команде, она находится в package.json
.
Заключение
Стандарт OFT оставил положительное впечатление. Он гибкий, дает широкие возможности настройки, а "из коробки" уже предоставляет весь необходимый функционал для создания простого ERC20 OFT-токена.
Отдельно стоит отметить удобство быстрого развертывания проекта:
Готовые тесты;
Скрипты hardhat и foundry для деплоя и настройки OApps в разных блокчейнах;
Инструменты для оценки газа.
Несмотря на надежность канала передачи данных LayerZero, всегда есть риск пограничных кейсов, особенно если токен включает комиссии, административные функции или сложные механики.
Поэтому важно:
Тщательно тестировать токен
Проводить аудит кода
Проверять поведение в мейннете, так как большая часть протокола работает офчейн (DVN, стек безопасности, Executor). В тестовых сетях оценка комиссий может давать некорректные результаты.
Практика показывает, что даже крупные проекты, такие как Tether, не застрахованы от ошибок. Аудит и внимательное тестирование — ключевые факторы безопасности.
В третьей части рассмотрим подробнее из чего формируются опции для передачи сообщения, а также затронем PreCrime.
Мы с коллегами периодически пишем в нашем Telegram-канале. Иногда это просто мысли вслух, иногда какие-то наблюдения с проектной практики. Не всегда всё оформляем в статьи, иногда проще написать пост в телегу. Так что, если интересно, что у нас в работе и что обсуждаем, можете заглянуть.