Skip to main content
Version: Endpoint V2 Docs

Read External State (LayerZero Read)

LayerZero Read extends the existing omnichain messaging protocol to enable developers to not only send cross-chain messages, but also request and retrieve on-chain state from other supported blockchains.

This new capability transforms how cross-chain data is accessed, manipulated, and computed, allowing developers to offload complex computations from on-chain applications to Decentralized Verifier Networks (DVNs).

Blockchain Query Language

lzRead is the implementation of LayerZero's Blockchain Query Language — a universal, standardized language for constructing, retrieving, and processing data requests across multiple blockchains and off-chain sources.

lzRead expands the traditional boundaries of on-chain data, allowing smart contracts to query current and historical states, both locally and externally, including potential Web 2.0 data sources.

Using lzRead, developers can configure secure, low-latency data queries and manage custom security settings, offering significant flexibility in balancing security, cost, and data accuracy.

The first implementation utilizes the ReadCodecV1, which initially enables reading calldata, public state variables, and functions that do not alter blockchain state (i.e., view and pure functions).

As lzRead evolves, additional capabilities such as querying event logs, private state variables, and even external data sources may be integrated, further extending the versatility of lzRead as a comprehensive data sourcing utility for on-chain applications.

Workflow

OFT Example OFT Example

  1. Request Definition: An OApp sends a lzRead command through endpointV2.send(), specifying the target chain and the block from which the state needs to be retrieved. This command is processed using a custom Send Library (readLib) that serializes the request and directs it to the appropriate chain via the application's configured DVN(s).

  2. DVN Fetch and Return: The DVN assigned to the request fetches the requested on-chain data from the target chain. The state is gathered through a node on the destination chain, and optionally processed off-chain via compute logic defined by the application in a target lzMap() and lzReduce() function, before finally being validated by the configured DVN threshold in the Receive Library (in this case the same readLib).

  3. Response Handling (lzReceive): Once the response is verified, the Executor delivers the response to the OApp on the source chain. The same receive workflow used by LayerZero's V2 protocol is triggered via endpoint.lzReceive().

  4. Execution and Custom Logic: The OApp processes the response data using the logic defined in the application smart contract via an internal _lzReceive(), allowing the developer to customize how the retrieved state is processed and used.

In this way, lzRead transforms the normal messaging workflow from message delivery to a request and response model.

Descriptionendpoint.sendendpoint.lzReceive
Omnichain MessageThe bytes sent match the bytes received.bytes _messagebytes _message
Omnichain ReadThe bytes request differs from the bytes response.bytes _requestbytes _response

Instead of sending a source message and using Decentralized Verifier Networks (DVNs) to deliver to the destination, you can now use Decentralized Verifier Networks (DVNs) to read directly from nodes on the destination blockchain.

Supported Chains

lzRead requires a read compatible Message Library and DVN, meaning you can only use lzRead on chains with both supported implementations deployed.

You can find these Message Libraries (ReadLib1002) and Compatible DVNs under "Read Paths".

Read DVNs Light Read DVNs Dark

Additionally, each DVN must also support the target chain to read from.

Installation

To start using LayerZero contracts, you can install the OApp package to an existing project:

npm install @layerzerolabs/oapp-evm
info

LayerZero contracts work with both OpenZeppelin V5 and V4 contracts. Specify your desired version in your project's package.json:

"resolutions": {
"@openzeppelin/contracts": "^5.0.1",
}

Usage

To start using lzRead, you will need to:

  1. Decide what origin chains to deploy your application on and what target data chains to read data from.

  2. Decide which lzRead compatible DVNs support your target chains.

  3. Deploy an lzRead compatible OApp.

  4. Set your application's send and receive library to ReadLib1002 via endpoint.setSendLibrary() and endpoint.setReceiveLibrary().

  5. Set your application's DVN Config via endpoint.setConfig().

Inherit OAppRead

Begin by inheriting the OAppRead.sol contract in your own smart contract. Familiarize yourself with the OAppRead implementation to understand how it interacts with LayerZero’s endpoint for both message and read commands.

import { OAppRead } from "@layerzerolabs/oapp-evm/contracts/oapp/OAppRead.sol";

import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol";


contract MyOApp is OAppRead {
constructor(address _endpoint, address _delegate) OAppRead(_endpoint, _delegate) Ownable(_delegate) {}
}

Decide Messaging Mode

Since lzRead is an extension to the base messaging protocol, determine the type of cross-chain functionality your app needs:

  1. LayerZero Messaging: For standard cross-chain messages without needing to read external state.

  2. LayerZero Reads (lzRead): To retrieve and use on-chain state from other blockchains.

  3. Hybrid (Both): To implement both messaging and read capabilities.

Here’s an example of how OAppRead is structured to handle both message-based and read-based responses by implementing a _messageLzReceive and _readLzReceive:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import {OAppRead} from "@layerzerolabs/oapp-evm/contracts/oapp/OAppRead.sol";
import {Origin} from "@layerzerolabs/oapp-evm/contracts/oapp/OApp.sol";

import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";

/// @title MsgAndReadExample
/// @notice An example contract that extends OAppRead to handle both messaging and read capabilities.
/// @dev Inherits from OApp and adds functionality specific to lzRead.
contract MsgAndReadExample is OAppRead {

/// lzRead responses are sent from arbitrary channels with Endpoint IDs in the range of
/// `eid > 4294965694` (which is `type(uint32).max - 1600`).
uint32 constant READ_CHANNEL_EID_THRESHOLD = 4294965694;

/// @param _endpoint The address of the LayerZero endpoint.
/// @param _delegate The address of the delegate contract.
constructor(address _endpoint, address _delegate) OAppRead(_endpoint, _delegate) Ownable(_delegate) {}

/// @notice Internal function to handle incoming messages and read responses.
/// @dev Filters messages based on `srcEid` to determine the type of incoming data.
/// @param _origin The origin information containing the source Endpoint ID (`srcEid`).
/// @param _guid The unique identifier for the received message.
/// @param _message The encoded message data.
/// @param _executor The executor address.
/// @param _extraData Additional data.
function _lzReceive(
Origin calldata _origin,
bytes32 _guid,
bytes calldata _message,
address _executor,
bytes calldata _extraData
) internal virtual override {
/**
* @dev The `srcEid` (source Endpoint ID) is used to determine the type of incoming message.
* - If `srcEid` is greater than READ_CHANNEL_EID_THRESHOLD (4294965694),
* it corresponds to arbitrary channel IDs for lzRead responses.
* - All other `srcEid` values correspond to standard LayerZero messages.
*/
if (_origin.srcEid > READ_CHANNEL_EID_THRESHOLD) {
// Handle lzRead responses from arbitrary channels.
_readLzReceive(_origin, _guid, _message, _executor, _extraData);
} else {
// Handle standard LayerZero messages.
_messageLzReceive(_origin, _guid, _message, _executor, _extraData);
}
}

/// @notice Internal function to handle standard LayerZero messages.
/// @dev _origin The origin information (unused in this implementation).
/// @dev _guid The unique identifier for the received message (unused in this implementation).
/// @param _message The encoded message data.
/// @dev _executor The executor address (unused in this implementation).
/// @dev _extraData Additional data (unused in this implementation).
function _messageLzReceive(
Origin calldata /* _origin */,
bytes32 /* _guid */,
bytes calldata _message,
address /* _executor */,
bytes calldata /* _extraData */
) internal virtual {
// Implement message handling logic here.
bool _messageDoSomething = abi.decode(_message, (bool));
}

/// @notice Internal function to handle lzRead responses.
/// @dev _origin The origin information (unused in this implementation).
/// @dev _guid The unique identifier for the received message (unused in this implementation).
/// @param _message The encoded message data.
/// @dev _executor The executor address (unused in this implementation).
/// @dev _extraData Additional data (unused in this implementation).
function _readLzReceive(
Origin calldata /* _origin */,
bytes32 /* _guid */,
bytes calldata _message,
address /* _executor */,
bytes calldata /* _extraData */
) internal virtual {
// Implement lzRead response handling logic here.
bool _readDoSomething = abi.decode(_message, (bool));
}
}

Build Read Query

To retrieve data from other chains and process it upon receipt, you need to construct read requests and define compute logic. Below is a step-by-step guide on how to construct these read requests, using an example where we read the price of a Uniswap V3 pool token from multiple chains.

Getting the Command

As mentioned above, lzRead uses the same send interface as traditional message passing via endpoint.send.

To construct the read query that will be used to determine what target chains to read data from, you will encode your requests as bytes and pass the encoding as the bytes _message parameter. From here, we'll refer to the _message as the read command, or _cmd.

Here's an example of how you can initiate the read request:

/**
* @notice Sends a read request to LayerZero, querying Uniswap QuoterV2 for WETH/USDC prices on configured chains.
* @param _extraOptions Additional messaging options, including gas and fee settings.
* @return receipt The LayerZero messaging receipt for the request.
*/
function readAverageUniswapPrice(
bytes calldata _extraOptions
) external payable returns (MessagingReceipt memory receipt) {
bytes memory cmd = getCmd();
return
_lzSend(
READ_CHANNEL,
cmd,
combineOptions(READ_CHANNEL, READ_MSG_TYPE, _extraOptions),
MessagingFee(msg.value, 0),
payable(msg.sender)
);
}

The getCmd() function is responsible for building the command that includes both the read requests and any compute instructions.

For example, this getCmd() creates multiple read requests and a compute request:

/**
* @notice Constructs a command to query the Uniswap QuoterV2 for WETH/USDC prices on all configured chains.
* @return cmd The encoded command to request Uniswap quotes.
*/
function getCmd() public view returns (bytes memory) {
uint256 pairCount = targetEids.length;
EVMCallRequestV1[] memory readRequests = new EVMCallRequestV1[](pairCount);

for (uint256 i = 0; i < pairCount; i++) {
uint32 targetEid = targetEids[i];
ChainConfig memory config = chainConfigs[targetEid];

// Define the QuoteExactInputSingleParams
IQuoterV2.QuoteExactInputSingleParams memory params = IQuoterV2.QuoteExactInputSingleParams({
tokenIn: config.tokenInAddress,
tokenOut: config.tokenOutAddress,
amountIn: 1 ether, // amountIn: 1 WETH
fee: config.fee,
sqrtPriceLimitX96: 0 // No price limit
});

// @notice Encode the function call
// @dev From Uniswap Docs, this function is not marked view because it relies on calling non-view
// functions and reverting to compute the result. It is also not gas efficient and should not
// be called on-chain. We take advantage of lzRead to call this function off-chain and get the result
// returned back on-chain to the OApp's _lzReceive method.
// https://docs.uniswap.org/contracts/v3/reference/periphery/interfaces/IQuoterV2
bytes memory callData = abi.encodeWithSelector(IQuoterV2.quoteExactInputSingle.selector, params);

readRequests[i] = EVMCallRequestV1({
appRequestLabel: uint16(i + 1),
targetEid: targetEid,
isBlockNum: false,
blockNumOrTimestamp: uint64(block.timestamp),
confirmations: config.confirmations,
to: config.quoterAddress,
callData: callData
});
}

EVMCallComputeV1 memory computeSettings = EVMCallComputeV1({
computeSetting: 2, // lzMap() and lzReduce()
targetEid: ILayerZeroEndpointV2(endpoint).eid(),
isBlockNum: false,
blockNumOrTimestamp: uint64(block.timestamp),
confirmations: 15,
to: address(this)
});

return ReadCodecV1.encode(0, readRequests, computeSettings);
}
tip

Following best practices, you should create a dedicated function to construct your specific command requests.

This involves creating an array of EVMCallRequestV1 structs, each representing a read operation on a specific chain and contract.

info

_lzSend is an internal virtual method provided in the OApp contract standard, which can be used to invoke the endpoint.send if certain security checks pass:

function _lzSend(
uint32 _dstEid,
bytes memory _message,
bytes memory _options,
MessagingFee memory _fee,
address _refundAddress
) internal virtual returns (MessagingReceipt memory receipt) {
// @dev Push corresponding fees to the endpoint, any excess is sent back to the _refundAddress from the endpoint.
uint256 messageValue = _payNative(_fee.nativeFee);
if (_fee.lzTokenFee > 0) _payLzToken(_fee.lzTokenFee);

return
// solhint-disable-next-line check-send-result
endpoint.send{ value: messageValue }(
MessagingParams(_dstEid, _getPeerOrRevert(_dstEid), _message, _options, _fee.lzTokenFee > 0),
_refundAddress
);
}

If using the OApp standard, consider _lzSend the recommended way to call endpoint.send.


Call Requests

Each EVMCallRequestV1 struct represents a read request and includes the following fields:

readRequests[i] = EVMCallRequestV1({
appRequestLabel: uint16(i + 1),
targetEid: targetEid,
isBlockNum: false,
blockNumOrTimestamp: uint64(block.timestamp),
confirmations: config.confirmations,
to: config.quoterAddress,
callData: callData
});
NameTypeDescription
appRequestLabeluint16A label to identify the request within your application logic. Useful for tracking response types.
targetEiduint32The Endpoint ID of the target chain where the data is to be read from.
isBlockNumboolA boolean indicating whether blockNumOrTimestamp is a block number (true) or a timestamp (false).
blockNumOrTimestampuint64Specifies the block.number or block.timestamp on the target chain (targetEid) at which to read the state.
confirmationsuint16The number of confirmations required to wait for the block or timestamp finality on the target chain.
toaddressThe target contract address on the destination chain (e.g., the Uniswap V3 Quoter contract).
callDatabytesThe ABI-encoded function calldata.

The callData field contains the encoded function call that will be executed on the target contract.

For example, IQuoterV2.quoteExactInputSingle.selector specifies the quoteExactInputSingle function in the Uniswap V3 QuoterV2 contract to request a price quote for swapping tokenIn to tokenOut on the specified Uniswap V3 pool.

info

The EVMCallRequestV1 struct is limited to chain calldata because it encodes function calls into a format that can be transmitted and understood across chains using the EVM's ABI encoding.

This limitation means that lzRead can only handle data types compatible with calldata, specifically value types and fixed-size data structures.

Compute Logic

You can also specify compute instructions to process the retrieved data after it's read. This is done using the EVMCallComputeV1 struct.

EVMCallComputeV1 memory computeSettings = EVMCallComputeV1({
computeSetting: 2, // lzMap() and lzReduce()
targetEid: ILayerZeroEndpointV2(endpoint).eid(),
isBlockNum: false,
blockNumOrTimestamp: uint64(block.timestamp),
confirmations: 15,
to: address(this)
});
NameTypeDescription
computeSettinguint8Specifies the compute operations to perform, such as lzMap (0), lzReduce, (1), or map and reduces (2). More on this later.
targetEiduint32The Endpoint ID of the target chain where the lzMap or lzReduce implementation can be read from.
isBlockNumboolA boolean indicating whether blockNumOrTimestamp is a block number (true) or a timestamp (false).
blockNumOrTimestampuint64Specifies the block.number or block.timestamp on the target chain at which to read the state.
confirmationsuint16The number of confirmations required to wait for the block or timestamp finality on the target chain.
toaddressThe target contract address to view lzMap and / or lzReduce.
info

Note: The next section will dive deeper into how to define the compute logic within lzMap and lzReduce.

Encoding Command

Once you have defined the command's EVMCallRequestV1 and the EVMCallComputeV1, you can use the CmdCodecV1 to encode the command to end your getter function.

int16 appLabel = 0; // Application label (set as needed)

// Encoding the command
return CmdCodecV1.encode(appLabel, readRequests, evmCompute);
caution

Different chains have different interpretations of block.number and block.timestamp.

For example, when calling block.number on Arbitrum L2 the value returned is the block number on Ethereum L1, rather than block.number of the Arbitrum chain itself.

Consider how these edge cases specific to each targetEid impact your application before finalizing the encoding process for your command from EVMCallRequestV1 and EVMCallComputeV1.

Declare Compute Logic

Compute logic is executed off-chain via your application's configured Decentralized Verifier Networks (DVNs). To define specific compute logic, you must target a contract that implements either IOAppMapper or IOAppReducer, which define the lzMap() and lzReduce() functions.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

/// @title IOAppMapper Interface
/// @notice Defines the lzMap function for mapping operations
interface IOAppMapper {

/**
* @notice Processes a single mapping operation
* @param _request The request data in bytes
* @param _response The response data in bytes
* @return A bytes array resulting from the mapping operation
*/
function lzMap(
bytes calldata _request,
bytes calldata _response
) external view returns (bytes memory);
}

/// @title IOAppReducer Interface
/// @notice Defines the lzReduce function for reducing operations
interface IOAppReducer {

/**
* @notice Processes a reduction operation over multiple responses
* @param _cmd The command data in bytes
* @param _responses An array of response data in bytes
* @return A bytes array resulting from the reduction operation
*/
function lzReduce(
bytes calldata _cmd,
bytes[] calldata _responses
) external view returns (bytes memory);
}

These optional view or pure functions enable your DVNs to read and compute state changes based on the _response or _responses from each request.

Compute Settings

When you declare an EVMCallComputeV1, you also select a compute setting for whether your configured DVNs should mutate the _response data using lzMap() (0), lzReduce(), (1), both lzMap() and lzReduce() (2), or no compute setting (3).

uint8 internal constant MAP_ONLY = 0;
uint8 internal constant REDUCE_ONLY = 1;
uint8 internal constant MAP_AND_REDUCE = 2;
uint8 internal constant NONE = 3;

EVMCallComputeV1 memory evmCompute = EVMCallComputeV1({
computeSetting: MAP_AND_REDUCE, // lzMap() and lzReduce()
targetEid: eid, // Current chain's EID
isBlockNum: true,
blockNumOrTimestamp: uint64(block.number),
confirmations: 2,
to: address(this) // Compute executed in this contract
});
  • If only lzMap(), the returned data to OApp._lzReceive() will be the concatenation of every return output for each _request your lzMap() processes. You will need to track the index of each request made in the command to later decode in your receive logic.

  • If only lzReduce, the lzReduce() implementation will intake the concatenation in order of how the EVMCallRequestV1[] were made as a bytes argument, and return a single bytes output. You can use lzReduce() to aggregate all responses.

  • If both, data will be manipulated on a per request level first via lzMap() and an aggregate level via lzReduce(), before being returned to OApp._lzReceive().

Implementation details can be found below.

Mapping Requests

Mapping refers to defining how to format or process the data returned by each individual request. In the Uniswap example, the lzMap() simplifies the returned data by only decoding the amountOut for use in the lzReduce() step.

/**
* @notice Processes individual Uniswap QuoterV2 responses, encoding the result.
* @param _response The response from the read request.
* @return Encoded token output amount (USDC amount).
*/
function lzMap(bytes calldata, bytes calldata _response) external pure returns (bytes memory) {
require(_response.length >= 32, "Invalid response length"); // quoteExactInputSingle returns multiple values

// Decode the response to extract amountOut
(uint256 amountOut, , , ) = abi.decode(_response, (uint256, uint160, uint32, uint256));
return abi.encode(amountOut);
}
info

The decoding above only decodes to demonstrate how an lzMap() can intake a _response, mutate the returned bytes per _request, and return a new encoded output depending on the needs of your application.

Reducing Requests

Once you've mapped the individual responses, the lzReduce() function aggregates these mapped responses to produce a final result.

In the Uniswap example, lzReduce() calculates the average amountOut from all responses.

/**
* @notice Aggregates individual token output amounts to compute an average.
* @param _responses Array of mapped responses containing token output amounts.
* @return Encoded average token output amount.
*/
function lzReduce(bytes calldata, bytes[] calldata _responses) external pure returns (bytes memory) {
require(_responses.length == 3, "Expected responses from 3 chains");
uint256 total = 0;
for (uint256 i = 0; i < _responses.length; i++) {
uint256 amountOut = abi.decode(_responses[i], (uint256));
total += amountOut;
}
uint256 averageAmountOut = total / _responses.length;
return abi.encode(averageAmountOut);
}

Creating Request Options

lzRead uses a new options type, addExecutorLzReadOption to send requests for target data.

OptionsBuilder.newOptions().addExecutorLzReadOption(100_000, 64, 0)

If unfamiliar with _options, you can read the full scope in Execution Options, but for reference this send parameter allows you to deliver an amount of gasLimit automatically to endpoint.lzReceive from your configured Executor.

For lzRead, an additional requirement for _options is to profile the calldata size of your returned data type:

OptionsBuilder.newOptions().addExecutorLzReadOption(GAS_LIMIT, CALLDATA_SIZE, MSG_VALUE)

These _options can be enforced as usual by inheriting the Enforced Options Helper in your OAppRead contract:

import { OAppRead } from "@layerzerolabs/oapp-evm/contracts/oapp/OAppRead.sol";
import { OAppOptionsType3, EnforcedOptionParam } from "@layerzerolabs/oapp-evm/contracts/oapp/libs/OAppOptionsType3.sol";

contract MyOAppRead is OAppRead, OAppOptionsType3 {}
caution

Adding other _options in the endpoint.send call to a channelId will cause the transaction to revert on source.

Receive Responses

Finally, you need to define your internal handler for incoming messages from the LayerZero protocol. This is where you'll process the final aggregated result and use it within your contract.

function _lzReceive(
Origin calldata _origin,
bytes32 _guid,
bytes calldata _message,
address _executor,
bytes calldata _extraData
) internal

In the Uniswap example, the handler processes the message and emits an event with the average token price:

/// @notice Emitted when the average amount out is computed and received.
event AggregatedPrice(uint256 averageAmountOut);

/**
* @notice Handles the aggregated average price from Uniswap V3 pool responses received from LayerZero.
* @dev Emits the AggregatedPrice event with the calculated average amount.
* @param _message Encoded average token output amount.
*/
function _lzReceive(
Origin calldata /*_origin*/,
bytes32 /*_guid*/,
bytes calldata _message,
address /*_executor*/,
bytes calldata /*_extraData*/
) internal override {
require(_message.length == 32, "Invalid message length");
uint256 averagePrice = abi.decode(_message, (uint256));
emit AggregatedPrice(averagePrice);
}

By using lzRead, you can drastically simplify the amount of logic needed for updating state on-chain.

Setting Libraries and DVNs

Set your configuration for the Read Library and required DVNs that support lzRead. See all available DVNs.

You can set your DVN configuration either via the LayerZero CLI tool or via example scripts.

Setting Read Channel

Once your configuration has been applied, simply call setReadChannel for the specific channel configuration to set and begin sending messages!

Channels let you define multiple types of read logic per application. You can specify per request label, per application label, or per channel.

// OAppRead.sol
// Only Owner
function setReadChannel(uint32 _channelId, bool _active) public virtual onlyOwner {
_setPeer(_channelId, _active ? AddressCast.toBytes32(address(this)) : bytes32(0));
}

To see the available channels for each target data chain, see Read Paths.

Debugging Read Commands

When working with lzRead, you might encounter issues where read commands (Requests and Compute logic) fail to execute as expected. LayerZero provides tools and best practices to help you debug these scenarios effectively.

When checking LayerZero Scan for the read status, you may encounter the following errors.

Malformed Command

Malformed indicates that the read command was constructed incorrectly and does not adhere to the required serialization format. This can be due to:

  • Incorrect packing of the command parameters.

  • Errors in encoding the calldata.

  • Misuse of the ReadCodecV1.sol library during command construction.

To resolve a Malformed Command, review how the command was packed on the origin chain by examining your getCmd implementation. Ensure that you are correctly using the ReadCodecV1.sol library for serialization.

Unresolvable Command

The target data chain cannot fulfill the read request due to issues related to invoking the calldata on the target chain, such as:

Contract Address Issues:

  • The specified contract does not exist on the target chain.

  • The contract address is incorrect for the given block number or timestamp.

Method Identifier Issues:

  • The calldata contains an invalid or non-existent method selector.

State Issues:

  • The contract is deployed, but the state at the specified block.number or block.timestamp does not support the requested operation.

To resolve an Unresolvable Command:

  • Ensure that the contract exists at the specified address on the target chain.

  • Verify that the contract is deployed and initialized correctly.

  • Double-check the contract address and ensure it matches the target chain's deployment.

  • Confirm that the block.number or block.timestamp specified in the read request corresponds to a state where the contract is available.

  • Ensure that the method selector used in the calldata exists in the target contract.

  • Verify that the function signatures match and that the target contract implements the requested methods.

You can use the Read CLI Debugging Task to help with the debugging process by ensuring the command decodes and reads the target data chain correctly.