Если push-модель LayerZero вам уже понятна (отправили сообщение из одной сети и получили его в другой), то следующий шаг — это научиться читать данные из других блокчейнов, не разворачивая контракт в целевой сети и не отправляя два отдельных сообщения «туда-обратно». В LayerZero v2 для этого предусмотрен механизм Omnichain Queries, более известный как lzRead.
О нем далее и поговорим!
Если вы вдруг пропустили третью часть, то можно посмотреть у коллеги тут.

В статье рассмотрим, как устроен lzRead, из каких контрактов он состоит, как написать и настроить контракт для получения цен из пула Uniswap V3 — с разбором кода и деплоем в Remix.
Терминология:
Исходная сеть (origin chain) — сеть, где развернут ваш контракт, который будет запрашивать данные из другой сети.
Сеть данных (data chain / target chain) — сеть, из которой вы читаете данные.
Endpoint — системный смарт-контракт в каждой сети от LayerZero, через который проходят входящие и исходящие сообщения.
EID (Endpoint ID) — числовой идентификатор сети в протоколе LayerZero.
Read Channel — отдельный канал сообщений именно для чтений; его ID и поддерживаемые пути приведены в таблицах деплоев.
DVN (Decentralized Verifier Network) — сеть верификаторов, подтверждающих корректность ответа.
ReadLib1002 — message-library для чтений; для lzRead нужны совместимые библиотеки и DVN с доступом к архивным нодам.
Как устроен lzRead
lzRead позволяет контракту запрашивать и получать состояние из других блокчейнов. В основе лежит идея BQL (Blockchain Query Language) — единый способ формулировать запросы (что читать, из какой сети, на каком блоке/времени), получать и при необходимости обрабатывать ответы.

По шагам:
Формирование запроса — приложение собирает запрос: какие данные нужны, из какой целевой сети, на каком блоке или времени. Запрос кодируется в стандартную команду по схеме BQL.
Отправка запроса — команда отправляется через Endpoint LayerZero по отдельному read-каналу (не обычному messaging). По каналу явно передается, что это запрос с ожиданием ответа, а не просто смена состояния.
Получение и верификация данных (DVN data fetch and verification) — DVN принимают запрос, забирают данные с архивной ноды требуемой сети и при необходимости применяют off-chain compute: lzMap (преобразование ответов из одной или нескольких сетей) и lzReduce (агрегация нескольких ответов в один). Каждый DVN формирует криптографический хеш результата для проверки целостности. В этой статье мы делаем один запрос в одну сеть, поэтому Compute не настраиваем; как задать lzMap/lzReduce для сценариев с несколькими сетями или агрегацией — можно посмотреть в документации lzRead.
Доставка ответа (Response handling) — после верификации нужным числом DVN Endpoint доставляет итоговый ответ обратно в исходную сеть. Контракт-получатель обрабатывает его в
_lzReceive(): декодирует payload и использует полученные данные.
Архитектура контрактов

Чтобы контракт мог отправлять read-запросы и получать ответы, нужно наследоваться от OAppRead.sol. Цепочка наследования:
OApp -> OAppReceiver.sol и OAppSender.sol
OAppReceiver, OAppSender -> OAppCore.sol
OAppCore -> Ownable (OpenZeppelin)
Контракты простые — имеет смысл просмотреть их перед следующим этапом.
Чтобы реализовать lzRead, нужен контракт, наследующий OAppRead и реализующий три части: формирование запроса, оценка комиссии, обработка ответа. В следующем разделе — пример такого контракта и его методы.
Пример OApp контракта (UniswapV3ObserveRead.sol)
Мы уже написали готовый контракт UniswapV3ObserveRead.sol. Он запрашивает с другой сети (data chain) результат вызова observe() у пула Uniswap V3 — накопленные за указанный период значения тика и ликвидности; по ним можно вычислить TWAP (Time-Weighted Average Price) — среднюю цену актива за период без развертывания контракта в сети пула. Ответ доставляется обратно в наш контракт в origin. Контракт наследует OAppRead и OAppOptionsType3.
OAppRead — отправка read-запроса и прием ответа в
_lzReceive.OAppOptionsType3 — библиотека для опций сообщений. Owner задает принудительные опции (enforced) через
setEnforcedOptions(EnforcedOptionParam[])для пар(eid, msgType); они хранятся вenforcedOptions[eid][msgType].combineOptions(eid, msgType, extraOptions)собирает итоговые опции: объединяет эти enforced с опциями вызывающего — параметромextraOptionsвquoteObserve/readObserve(на стороне executor значения складываются) и передает результат вlzSendиquote. Для lzRead опции в формате Type3: газ на доставку ответа, размер ответа в байтах (response size) и value для executor; сборка —addExecutorLzReadOption(gas, responseSizeBytes, value). Если enforced уже заданы с достаточным газом и response size, можно вызывать с_extraOptions = 0x, иначе — передать закодированные опции.
При деплое передаем пять аргументов:
constructor( address _endpoint, uint32 _readChannel, uint32 _targetEid, address _targetPoolAddress, address _config // контракт LzReadConfig — деплоим первым ) OAppRead(_endpoint, _config) Ownable(_config) { READ_CHANNEL = _readChannel; targetEid = _targetEid; targetPoolAddress = _targetPoolAddress; _setPeer(READ_CHANNEL, AddressCast.toBytes32(address(this))); }
_endpoint — адрес Endpoint в сети деплоя (origin). Берется из Chains для выбранной origin-сети.
_readChannel — идентификатор read-канала. Берется из таблицы по паре origin и data chain.
_targetEid — EID целевой сети (откуда читаем пул). Берется из Chains для выбранной data chain.
_targetPoolAddress — адрес пула Uniswap V3 в data chain.
config — адрес контракта LzReadConfig.sol (деплоить первым). Передаётся в OAppRead и в Ownable: конфиг сразу становится владельцем OApp, адрес на OApp не хранится. После деплоя на OApp вызовите
setDelegate(config). Позже (от владельца конфига): сменить делегата —setOAppDelegate(oapp, delegate); задать/сменить read-канал —setOAppReadChannel(oapp, channelId, active)(active = false чтобы отключить приём); передать владение —transferOAppOwnership(oapp, newOwner).
Внутри сохраняются READ_CHANNEL, targetEid, targetPoolAddress и вызывается setPeer(READCHANNEL, AddressCast.toBytes32(address(this))) — так мы говорим протоколу, что ответы по этому read-каналу доставлять на этот контракт.
Формирование запроса на чтение
Метод собирает read-команду с помощью библиотеки ReadCodecV1 (кодирование и декодирование вызовов).
Цель — закодировать вызов observe(secondsAgos) на пуле Uniswap V3.
function getCmd(uint32[] calldata secondsAgos) public view returns (bytes memory) { bytes memory callData = abi.encodeWithSelector(IUniswapV3PoolObserve.observe.selector, secondsAgos); EVMCallRequestV1[] memory req = new EVMCallRequestV1[](1); req[0] = EVMCallRequestV1({ appRequestLabel: 1, // метка запроса targetEid: targetEid, // EID сети, откуда читать данные isBlockNum: false, // читать по времени (true = по номеру блока) blockNumOrTimestamp: uint64(block.timestamp), // таймстамп, на котором будет считывание данных confirmations: 15, // сколько подтверждений блока нужно to: targetPoolAddress, // адрес контракта в data chain callData: callData // закодированный метод который будет вызван для получения информации }); return ReadCodecV1.encode(0, req); // версия 0, один запрос без Compute }
secondsAgos — массив «сколько секунд назад» для
observe; например[3600,0]— данные за последний час и «сейчас».
В массив можно добавить несколько запросов (в т. ч. в разные сети).
Оценка комиссии за запрос
Перед отправкой запроса вызываем view-функцию, чтобы узнать, сколько нативного токена (или LZ token) нужно отправить вместе с readObserve.
function quoteObserve( uint32[] calldata secondsAgos, // те же, что пойдут в readObserve bytes calldata _extraOptions, // закодированные опции для executor bool _payInLzToken // true = платить в LZ token, false = в нативном токене сети ) external view returns (MessagingFee memory fee);
Зачем payInLzToken: вы заранее говорите, чем будете платить при вызове readObserve — нативным токеном сети или LZ token. В fee возвращаются оба значения (nativeFee и lzTokenFee); используйте то, что соответствует вашему выбору: при false смотрите fee.nativeFee и передаете эту сумму в msg.value в readObserve, при true — fee.lzTokenFee, а оплата LZ token идет через механизм протокола (approve + списание). В статье дальше предполагается оплата нативным токеном (payInLzToken = false).
Возвращается структура fee (поля nativeFee, lzTokenFee).
Отправка read-запроса: readObserve
Отправляет сформированную командой getCmd(secondsAgos) read-запрос в read-канал. Вызывать payable, с msg.value >= fee.nativeFee (значение из quoteObserve).
function readObserve( uint32[] calldata secondsAgos, // массив для observe, например [3600, 0] bytes calldata _extraOptions // те же опции, что в quoteObserve (см. выше; при тесте 0x) ) external payable returns (MessagingReceipt memory receipt);
Возвращается MessagingReceipt (например, для отслеживания в сканере). Ответ придет асинхронно в _lzReceive.
Получение ответа: _lzReceive
Вызывается протоколом, когда верифицированный ответ доставлен в origin. В _message приходят закодированные возвращаемые значения observe(): два массива (int56[] tickCumulatives, uint160[] secondsPerLiquidityCumulativeX128s).
function _lzReceive( Origin calldata, // метаданные сообщения (srcEid, sender, nonce) bytes32, // guid сообщения bytes calldata _message, // ответ: abi-encoded (int56[], uint160[]) address, // executor bytes calldata ) internal override { (int56[] memory tickCumulatives, uint160[] memory secondsPerLiquidityCumulativeX128s) = abi.decode(_message, (int56[], uint160[])); emit ObserveResult(tickCumulatives, secondsPerLiquidityCumulativeX128s); }
Декодируем payload и эмитим событие ObserveResult — по нему можно убедиться, что данные пришли.
Конфигурация приложения
Настройка бывает двух видов: на endpoint (библиотеки send/receive, конфиг ReadLib с executor и требования к DVN) и на OApp (enforced options, при необходимости смена read-канала).
Вызывать настройку на endpoint может только сам OApp или его делегат; настройку на OApp (например setEnforcedOptions) — только владелец OApp. По этой причине, на UniswapV3ObserveRead.sol в конструкторе назначаем владельцем и делегатом наш контракт конфига.
Контракт конфига помогает настроить эти параметры LzReadConfig.sol.
Настраивать может деплоер, сам контракт (вызов от его адреса) или делегат — через setDelegate(address _delegate) из OAppCore.sol.
Порядок деплоя и настройки:
Задеплоить LzReadConfig.sol с одним аргументом:
_endpoint(адрес Endpoint для сети из таблицы).Задеплоить UniswapV3ObserveRead.sol, передав в аргументы тот же адрес endpoint и адрес только что задеплоенного контракта конфига.
Одним вызовом настроить и endpoint, �� OApp — метод конфига
configureFull(_oapp, readChannel, readLib, libConfig, receiveGracePeriod, _enforced)задаёт на endpoint библиотеки send/receive и конфиг ReadLib, а на OApp — enforced options (газ и размер ответа для lzRead). Аргументы:_oapp — адрес задеплоенного OApp (UniswapV3ObserveRead). Берёте из Remix после деплоя.
_readChannel — идентификатор read-канала для пары сетей (origin → data chain). Берётся из таблицы Read Data Channels по вашей сети и целевой.
_readLib — адрес библиотеки Read (например ReadLib1002). Тоже из той же таблицы для вашей сети.
_libConfig — конфиг ReadLib на endpoint:
(executor, requiredDVNCount, optionalDVNCount, optionalDVNThreshold, requiredDVNs[], optionalDVNs[]). Адреса executor, requiredDVNs и optionalDVNs и массив требуемых DVNs вы можете выбрать из таблицы._receiveGracePeriod — задержка активации receive-библиотеки в секундах; обычно 0 (сразу).
_enforced — принудительные опции для lzRead: структура из трех полей. eid (uint32) = тот же readChannel; msgType (uint16) = 1 для lzRead; options (bytes) — закодированные опции (gas, размер ответа в байтах, value).
Поле options: К сожалению в библиотеках layerZero нет доступа к вспомогательному методу кодировки этих параметров, поэтому я создал для вас тулзу кодировки, которой вы можете воспользоваться для удобства. Скачиваем файл тулзы и открываем в браузере.
В конструкторе OApp уже вызывается setPeer(READCHANNEL, ...). Менять read-канал или отключать приём ответов можно через конфиг: владелец конфига вызывает LzReadConfig.setOAppReadChannel(адрес OApp, channelId, active) (active = false чтобы отключить).
Альтернатива нашему мануальному способу деплою, конфигурации и способу чтению данных является уже репозиторием с уже готовым набором скриптов — LayerZero CLI.
Практика
Представим: в нашей сети (origin) Base Sepolia нет цены на токен, которая нужна контракту. Через lzRead можно запросить данные о цене с другой сети — например, с контракта пула Uniswap V3 в сети Ethereum Sepolia, где этот токен уже торгуется.
Ниже — пошаговый порядок: деплой контракта в origin, оценка комиссии через quoteObserve, отправка read-запроса через readObserve, проверка транзакции в LayerZero Scan и проверка результата (событие ObserveResult).
Можно повторить с указанными ниже адресами или подставить свои из списка, убедившись, что для пары сетей есть Read Path в Read Data Channels и в data chain есть подходящий пул Uniswap V3 с ликвидностью.
Совет: после деплоя сразу делайте Pin contract for current workspace (значок рядом с адресом контракта в Remix), а адреса копируйте — при смене сети развернутые контракты сбрасываются. Чтобы вызвать методы уже развернутого контракта, во вкладке Contract выберите контракт и вставьте его адрес в At Address.
В примере: origin = Base Sepolia, data chain = Ethereum Sepolia.
1.Откройте Remix и добавьте контракты LzReadConfig.sol и UniswapV3ObserveRead.sol.
2.Собираем все данные для деплоя:
Endpoint берем для сети origin с этой таблицы;
EID для data chain берем тут;
targetPoolAddress для target сети можем найти через Uniswap deployments. Убедитесь, что вызов observe на пуле возвращает данные;
readChannel находим в этой таблице, указывая сеть origin и сеть target data;
readLib для origin сети находим тут;
libConfigParams для origin сети тоже находим тут. Он включает в себя такие параметры как: executor, requiredDVNCount, optionalDVNCount, optionalDVNThreshold, requiredDVNs, optionalDVNs;
enforced использует уже известный readChannel, msgType = 1, и options, который мы кодируем через специальную созданную тулзу;


3.Задеплойте сначала LzReadConfig.sol (аргумент: endpoint), затем UniswapV3ObserveRead.sol (endpoint, readChannel, targetEid, targetPoolAddress, адрес LzReadConfig).


4.Далее, на LzReadConfig вызовите configureFull(OApp, readChannel, readLib, libConfigParams, 0, enforcedParams).

5.Оцените комиссию: quoteObserve(secondsAgos, extraOptions, false) на задеплоенном контракте UniswapV3ObserveRead.sol. Например, secondsAgos = [3600,0] для TWAP за последний час. extraOptions можно передать 0x — enforced options уже заданы.

6.В Remix в поле Value укажите fee.nativeFee (в Wei) и вызовите readObserve(secondsAgos, extraOptions).

7.После подтверждения транзакции, в сканере по адресу UniswapV3ObserveRead.sol можно найти состояние вашего запроса testnet.layerzeroscan.com.


8.После статуса Delivered в origin будет вызван _lzReceive и эмитировано событие ObserveResult. Проверить можно по ссылке на Response transaction в разделе логов.

Заключение
С lzRead ваш контракт в одной сети может запросить данные в другой и получить ответ обратно — без деплоя контрактов там и без двух отдельных сообщений туда-сюда. Вы формируете запрос, отправляете его по read-каналу и обрабатываете ответ в _lzReceive. Так же, как мы ранее говорили, есть дополнительный функционал — Compute (lzMap, lzReduce).
Ссылки
Мы с командой делимся мыслями и наблюд��ниями в своём Telegram-канале. Не всё доходит до статей, что-то проще оформить в пост, чем расписывать длинный текст. Если интересно, чем живём, что обсуждаем и с какими задачами сталкиваемся, то заглядывайте.
