Skip to main content
Version: Endpoint V2

Developer Overview

LayerZero is a smart contract protocol that allows smart contracts on different blockchain networks to communicate with each other.

Any data, whether it's a fungible token transfer (ERC20), the metadata of an NFT (ERC721), or Token Bound Accounts (ERC6551) can be encoded on-chain as a byte array, delivered to a destination chain, and decoded to trigger some action.

Protocol V2 Light Protocol V2 Dark

Message Workflow

  1. A user calls the OApp on the source chain and pays a fee to send a cross-chain message to the Endpoint.

  2. The Endpoint check the validity of the cross-chain message and assigns each job to the OApp configured DVNs (Decentralized Verifier Networks) and Executor to execute the cross-chain message.

  3. The DVNs verify the message on the destination chain. After the required and optional DVNs have verified the message, the message is to be inserted (committed) in the message channel of the Endpoint on the destination chain.

  4. After the message has been inserted in the Endpoint's message channel, the Executor calls Endpoint.lzReceive to trigger the execution of the cross-chain message on the destination chain.

  5. The Endpoint calls the payable ReceiverOApp.lzReceive to pass the message and execute the internal receive logic. You can modify the internal execution logic inside ReceiverOApp._lzReceive to trigger any intended outcome from the cross-chain message.


tip

You can find all of the above contracts by visiting Supported Chains and Supported DVNs.

Send Overview

The OApp calls EndpointV2.send to send the cross-chain message and pays a fee to each configured DVN and Executor.

EndpointV2.sol

Inside the send call:

  • emit event to each DVN and Executor according to the OApp send configuration for the cross-chain message. Also calculate and record the fee that should be paid to each DVN and Executor.

  • check whether the fees the user is willing to pay can cover the fees required by the DVNs and Executor.

  • transfer fee to _sendLibrary (which records fee allocation).

// LayerZero/V2/protocol/contracts/EndpointV2.sol

address public lzToken;

struct MessagingParams {
uint32 dstEid; // destination chain endpoint id
bytes32 receiver; // receiver on destination chain
bytes message; // cross-chain message
bytes options; // settings for executor and dvn
bool payInLzToken; // whether to pay in ZRO token
}

struct MessagingReceipt {
bytes32 guid; // unique identifier for the message
uint64 nonce; // message nonce
MessagingFee fee; // the message fee paid
}

/// @dev MESSAGING STEP 1 - OApp need to transfer the fees to the endpoint before sending the message
/// @param _params the messaging parameters
/// @param _refundAddress the address to refund both the native and lzToken
function send(
MessagingParams calldata _params,
address _refundAddress
) external payable sendContext(_params.dstEid, msg.sender) returns (MessagingReceipt memory) {
if (_params.payInLzToken && lzToken == address(0x0)) revert Errors.LZ_LzTokenUnavailable();

// send message
(MessagingReceipt memory receipt, address _sendLibrary) = _send(msg.sender, _params);

// OApp can simulate with 0 native value it will fail with error including the required fee, which can be provided in the actual call
// this trick can be used to avoid the need to write the quote() function
// however, without the quote view function it will be hard to compose an oapp on chain
uint256 suppliedNative = _suppliedNative();
uint256 suppliedLzToken = _suppliedLzToken(_params.payInLzToken);

// check fee sender has provided enough fee
_assertMessagingFee(receipt.fee, suppliedNative, suppliedLzToken);

// handle lz token fees to _sendLibrary
_payToken(lzToken, receipt.fee.lzTokenFee, suppliedLzToken, _sendLibrary, _refundAddress);

// handle native fees to _sendLibrary
_payNative(receipt.fee.nativeFee, suppliedNative, _sendLibrary, _refundAddress);

return receipt;
}

/// @dev Assert the required fees and the supplied fees are enough
function _assertMessagingFee(
MessagingFee memory _required,
uint256 _suppliedNativeFee,
uint256 _suppliedLzTokenFee
) internal pure {
if (_required.nativeFee > _suppliedNativeFee || _required.lzTokenFee > _suppliedLzTokenFee) {
revert Errors.LZ_InsufficientFee(
_required.nativeFee,
_suppliedNativeFee,
_required.lzTokenFee,
_suppliedLzTokenFee
);
}
}

// pay lzToken
function _payToken(
address _token,
uint256 _required,
uint256 _supplied,
address _receiver,
address _refundAddress
) internal {
if (_required > 0) {
Transfer.token(_token, _receiver, _required);
}
if (_required < _supplied) {
unchecked {
// refund the excess
Transfer.token(_token, _refundAddress, _supplied - _required);
}
}
}

// pay native token
function _payNative(
uint256 _required,
uint256 _supplied,
address _receiver,
address _refundAddress
) internal virtual {
if (_required > 0) {
Transfer.native(_receiver, _required);
}
if (_required < _supplied) {
unchecked {
// refund the excess
Transfer.native(_refundAddress, _supplied - _required);
}
}
}

Inside the internal _send call:

  • get the nonce of this packet according to the path: [sender, destination chain, receiver].

  • generate guid of the packet (global unique identifier).

  • get the _sendLibrary of the OApp (OApp can set their specific send library of each destination chain).

  • call _sendLibrary to emit events to notify Executor and DVNs, also calculate and record the fee that should be paid to each.

//  LayerZero/V2/protocol/contracts/EndpointV2.sol

mapping(address sender => mapping(uint32 dstEid => mapping(bytes32 receiver => uint64 nonce))) public outboundNonce;

/// @dev increase and return the next outbound nonce
function _outbound(address _sender, uint32 _dstEid, bytes32 _receiver) internal returns (uint64 nonce) {
unchecked {
nonce = ++outboundNonce[_sender][_dstEid][_receiver];
}
}

address private constant DEFAULT_LIB = address(0);
mapping(uint32 dstEid => address lib) public defaultSendLibrary;

/// @notice The Send Library is the Oapp specified library that will be used to send the message to the destination
/// endpoint. If the Oapp does not specify a Send Library, the default Send Library will be used.
/// @dev If the Oapp does not have a selected Send Library, this function will resolve to the default library
/// configured by LayerZero
/// @return lib address of the Send Library
/// @param _sender The address of the Oapp that is sending the message
/// @param _dstEid The destination endpoint id
function getSendLibrary(address _sender, uint32 _dstEid) public view returns (address lib) {
lib = sendLibrary[_sender][_dstEid];
if (lib == DEFAULT_LIB) {
lib = defaultSendLibrary[_dstEid];
if (lib == address(0x0)) revert Errors.LZ_DefaultSendLibUnavailable();
}
}

struct MessagingFee {
uint256 nativeFee;
uint256 lzTokenFee;
}

/// @dev internal function for sending the messages used by all external send methods
/// @param _sender the address of the application sending the message to the destination chain
/// @param _params the messaging parameters
function _send(
address _sender,
MessagingParams calldata _params
) internal returns (MessagingReceipt memory, address) {
// get the correct outbound nonce
uint64 latestNonce = _outbound(_sender, _params.dstEid, _params.receiver);

// construct the packet with a GUID
Packet memory packet = Packet({
nonce: latestNonce,
srcEid: eid,
sender: _sender,
dstEid: _params.dstEid,
receiver: _params.receiver,
guid: GUID.generate(latestNonce, eid, _sender, _params.dstEid, _params.receiver),
message: _params.message
});

// get the send library by sender and dst eid
address _sendLibrary = getSendLibrary(_sender, _params.dstEid);

// messageLib always returns encodedPacket with guid
(MessagingFee memory fee, bytes memory encodedPacket) = ISendLib(_sendLibrary).send(
packet,
_params.options,
_params.payInLzToken
);

// Emit packet information for DVNs, Executors, and any other offchain infrastructure to only listen
// for this one event to perform their actions.
emit PacketSent(encodedPacket, _params.options, _sendLibrary);

return (MessagingReceipt(packet.guid, latestNonce, fee), _sendLibrary);
}

The guid is generated using the following parameters:

// LayerZero/V2/protocol/contracts/libs/GUID.sol
function generate(
uint64 _nonce,
uint32 _srcEid,
address _sender,
uint32 _dstEid,
bytes32 _receiver
) internal pure returns (bytes32) {
return keccak256(abi.encodePacked(_nonce, _srcEid, _sender.toBytes32(), _dstEid, _receiver));
}

SendUln302.sol

Next, the message is handled by the OApp selected Send Library. For example, SendUln302.send:

  • pay workers (DVNs and Executor) and treasury. In the send process, the fee is not directly paid to the workers, but recorded in the send library (SendUln302.sol) for workers to claim later.

  • call DVNs and Executor's contract to emit event to notify them to send cross-chain message.

// LayerZero/V2/messagelib/contracts/uln/uln302/SendUln302.sol

struct Packet {
uint64 nonce;
uint32 srcEid;
address sender;
uint32 dstEid;
bytes32 receiver;
bytes32 guid;
bytes message;
}

function send(
Packet calldata _packet,
bytes calldata _options,
bool _payInLzToken
) public virtual onlyEndpoint returns (MessagingFee memory, bytes memory) {
// assign job to Executor and DVN, calculate fees
(bytes memory encodedPacket, uint256 totalNativeFee) = _payWorkers(_packet, _options);

// calculate and pay the treasury fee, if enabled
(uint256 treasuryNativeFee, uint256 lzTokenFee) = _payTreasury(
_packet.sender,
_packet.dstEid,
totalNativeFee,
_payInLzToken
);
totalNativeFee += treasuryNativeFee;

return (MessagingFee(totalNativeFee, lzTokenFee), encodedPacket);
}

Inside the SendUln302._payWorkers, the contract:

  • splits options to get executorOptions (Executor) and validationOptions (DVN).

  • get the OApp set Executor and corresponding maxMessageSize (If not set, then a default maxMessageSize of 10000 bytes is used), and checks that the size of the message to send is less than than the max.

  • calls _payExecutor to assign job to corresponding Executor and record the fee paid.

  • calls _payVerifier to assign job to specified DVNs and record fee paid.

// LayerZero/V2/messagelib/contracts/uln/uln302/SendUln302.sol

/// 1/ handle executor
/// 2/ handle other workers
function _payWorkers(
Packet calldata _packet,
bytes calldata _options
) internal returns (bytes memory encodedPacket, uint256 totalNativeFee) {
// split workers options
(bytes memory executorOptions, WorkerOptions[] memory validationOptions) = _splitOptions(_options);

// handle executor
ExecutorConfig memory config = getExecutorConfig(_packet.sender, _packet.dstEid);
uint256 msgSize = _packet.message.length;
_assertMessageSize(msgSize, config.maxMessageSize);
totalNativeFee += _payExecutor(config.executor, _packet.dstEid, _packet.sender, msgSize, executorOptions);

// handle other workers
(uint256 verifierFee, bytes memory packetBytes) = _payVerifier(_packet, validationOptions); //for ULN, it will be dvns
totalNativeFee += verifierFee;

encodedPacket = packetBytes;
}

// @dev get the executor config and if not set, return the default config
function getExecutorConfig(address _oapp, uint32 _remoteEid) public view returns (ExecutorConfig memory rtnConfig) {
ExecutorConfig storage defaultConfig = executorConfigs[DEFAULT_CONFIG][_remoteEid];
ExecutorConfig storage customConfig = executorConfigs[_oapp][_remoteEid];

uint32 maxMessageSize = customConfig.maxMessageSize;
rtnConfig.maxMessageSize = maxMessageSize != 0 ? maxMessageSize : defaultConfig.maxMessageSize;

address executor = customConfig.executor;
rtnConfig.executor = executor != address(0x0) ? executor : defaultConfig.executor;
}

function _assertMessageSize(uint256 _actual, uint256 _max) internal pure {
if (_actual > _max) revert LZ_MessageLib_InvalidMessageSize(_actual, _max);
}

Inside the SendUln302._payExecutor:

  • calls Executor (default or set by OApp) to assign job and calculate the fee needed.

  • record the Executor’s fee inside the send library.


// LayerZero/V2/messagelib/contracts/uln/uln302/SendUln302.sol
function _payExecutor(
address _executor,
uint32 _dstEid,
address _sender,
uint256 _msgSize,
bytes memory _executorOptions
) internal returns (uint256 executorFee) {
executorFee = ILayerZeroExecutor(_executor).assignJob(_dstEid, _sender, _msgSize, _executorOptions);
if (executorFee > 0) {
fees[_executor] += executorFee;
}
emit ExecutorFeePaid(_executor, executorFee);
}

Inside the SendUln302._payVerifier:

  • calculate payloadHash and payload, which will be used to emit event to notify DVN to send the cross-chain message.

    • payloadHash is a digest including information about the version and path of the cross-chain message;

    • payload includes information of the guid and the body of the cross-chain message.

  • get the sender OApp config about which DVNs to use.

  • assign job for each DVN, including both required and optional.

// LayerZero/V2/messagelib/contracts/uln/uln302/SendUln302.sol
function _payVerifier(
Packet calldata _packet,
WorkerOptions[] memory _options
) internal override returns (uint256 otherWorkerFees, bytes memory encodedPacket) {
(otherWorkerFees, encodedPacket) = _payDVNs(fees, _packet, _options);
}

struct WorkerOptions {
uint8 workerId;
bytes options;
}

// accumulated fees for workers and treasury
mapping(address worker => uint256) public fees;

struct AssignJobParam {
uint32 dstEid;
bytes packetHeader;
bytes32 payloadHash;
uint64 confirmations; // source chain block confirmations before message being verified on the destination
address sender;
}

struct UlnConfig {
uint64 confirmations;
// we store the length of required DVNs and optional DVNs instead of using DVN.length directly to save gas
uint8 requiredDVNCount; // 0 indicate DEFAULT, NIL_DVN_COUNT indicate NONE (to override the value of default)
uint8 optionalDVNCount; // 0 indicate DEFAULT, NIL_DVN_COUNT indicate NONE (to override the value of default)
uint8 optionalDVNThreshold; // (0, optionalDVNCount]
address[] requiredDVNs; // no duplicates. sorted an an ascending order. allowed overlap with optionalDVNs
address[] optionalDVNs; // no duplicates. sorted an an ascending order. allowed overlap with requiredDVNs
}

/// ---------- pay and assign jobs ----------
function _payDVNs(
mapping(address => uint256) storage _fees,
Packet memory _packet,
WorkerOptions[] memory _options
) internal returns (uint256 totalFee, bytes memory encodedPacket) {
// calculate packetHeader and payload
bytes memory packetHeader = PacketV1Codec.encodePacketHeader(_packet);
bytes memory payload = PacketV1Codec.encodePayload(_packet);
bytes32 payloadHash = keccak256(payload);
uint32 dstEid = _packet.dstEid;
address sender = _packet.sender;

// get user’s config about DVN
UlnConfig memory config = getUlnConfig(sender, dstEid);

// if options is not empty, it must be dvn options
bytes memory dvnOptions = _options.length == 0 ? bytes("") : _options[0].options;
uint256[] memory dvnFees;

// assign job for each DVN includes those required and optional
(totalFee, dvnFees) = _assignJobs(
_fees,
config,
ILayerZeroDVN.AssignJobParam(dstEid, packetHeader, payloadHash, config.confirmations, sender),
dvnOptions
);
encodedPacket = abi.encodePacked(packetHeader, payload);

emit DVNFeePaid(config.requiredDVNs, config.optionalDVNs, dvnFees);
}
// LayerZero/V2/protocol/contracts/messagelib/libs/PacketV1Codec.sol
function encodePacketHeader(Packet memory _packet) internal pure returns (bytes memory) {
return
abi.encodePacked(
PACKET_VERSION,
_packet.nonce,
_packet.srcEid,
_packet.sender.toBytes32(),
_packet.dstEid,
_packet.receiver
);
}

function encodePayload(Packet memory _packet) internal pure returns (bytes memory) {
return abi.encodePacked(_packet.guid, _packet.message);
}

Inside the SendUln302._assignJobs:

  • call each required and optional DVN to notify them to verify the cross-chain message on the destination chain.

  • update each DVN's fee.

  • return the totalFee used by all DVNs.

// LayerZero/V2/messagelib/contracts/uln/uln302/SendUln302.sol
function _assignJobs(
mapping(address => uint256) storage _fees,
UlnConfig memory _ulnConfig,
ILayerZeroDVN.AssignJobParam memory _param,
bytes memory dvnOptions
) internal returns (uint256 totalFee, uint256[] memory dvnFees) {
(bytes[] memory optionsArray, uint8[] memory dvnIds) = DVNOptions.groupDVNOptionsByIdx(dvnOptions);

uint8 dvnsLength = _ulnConfig.requiredDVNCount + _ulnConfig.optionalDVNCount;
dvnFees = new uint256[](dvnsLength);
for (uint8 i = 0; i < dvnsLength; ++i) {
address dvn = i < _ulnConfig.requiredDVNCount
? _ulnConfig.requiredDVNs[i]
: _ulnConfig.optionalDVNs[i - _ulnConfig.requiredDVNCount];

bytes memory options = "";
for (uint256 j = 0; j < dvnIds.length; ++j) {
if (dvnIds[j] == i) {
options = optionsArray[j];
break;
}
}

dvnFees[i] = ILayerZeroDVN(dvn).assignJob(_param, options);
if (dvnFees[i] > 0) {
_fees[dvn] += dvnFees[i];
totalFee += dvnFees[i];
}
}
}

Assign Job to Executor

Executor.assignJob calls ExecutorFeeLib.getFeeOnSend to calculate the fee that should be paid to the Executor, and emit an event to notify.

In the ExecutorFeeLib.getFeeOnSend, it will check the msg.value specified by the message sender and enforce that it should be smaller than the DstConfig.nativeCap of the destination chain. This is because the supply of native tokens (e.g., Ether) must be maintained by the Executor, and is not controlled by the OApp unless running a custom Executor.

// LayerZero/V2/messagelib/contracts/Executor.sol
struct FeeParams {
address priceFeed;
uint32 dstEid;
address sender;
uint256 calldataSize;
uint16 defaultMultiplierBps;
}

struct DstConfig {
uint64 baseGas; // for verifying / fixed calldata overhead
uint16 multiplierBps;
uint128 floorMarginUSD; // uses priceFeed PRICE_RATIO_DENOMINATOR
uint128 nativeCap; // maximum native gas token cap
}

function assignJob(
uint32 _dstEid,
address _sender,
uint256 _calldataSize,
bytes calldata _options
) external onlyRole(MESSAGE_LIB_ROLE) onlyAcl(_sender) returns (uint256 fee) {
IExecutorFeeLib.FeeParams memory params = IExecutorFeeLib.FeeParams(
priceFeed,
_dstEid,
_sender,
_calldataSize,
defaultMultiplierBps
);
fee = IExecutorFeeLib(workerFeeLib).getFeeOnSend(params, dstConfig[_dstEid], _options);
}

Assign Job to DVNs

DVN.assignJob calls DVNFeeLib.getFeeOnSend to calculate the fee that should be paid to the DVNs, and emit events to notify them.


// LayerZero/V2/messagelib/contracts/uln/dvn/DVN.sol
/// @dev for ULN301, ULN302 and more to assign job
/// @dev dvn network can reject job from _sender by adding/removing them from allowlist/denylist
/// @param _param assign job param
/// @param _options dvn options
function assignJob(
AssignJobParam calldata _param,
bytes calldata _options
) external payable onlyRole(MESSAGE_LIB_ROLE) onlyAcl(_param.sender) returns (uint256 totalFee) {
IDVNFeeLib.FeeParams memory feeParams = IDVNFeeLib.FeeParams(
priceFeed,
_param.dstEid,
_param.confirmations,
_param.sender,
quorum,
defaultMultiplierBps
);
totalFee = IDVNFeeLib(workerFeeLib).getFeeOnSend(feeParams, dstConfig[_param.dstEid], _options);
}

Send Limitations

Max Message Bytes Size

The maxMessageSize depends on the Send Library. In SendUln302, the default max is 10000 bytes, but this value can be configured per OApp.

Max Native Gas Token Requests

In the ExecutorFeeLib._decodeExecutorOptions, it limits the maximum native gas token amount that can be requested from the Executor for the destination chain transaction.

This config is set in Executor.dstConfig:

// LayerZero/V2/messagelib/contracts/Executor.sol
struct DstConfig {
uint64 baseGas; // for verifying / fixed calldata overhead
uint16 multiplierBps;
uint128 floorMarginUSD; // uses priceFeed PRICE_RATIO_DENOMINATOR
uint128 nativeCap; // maximum native gas token amount to request from Executor for destination chain transaction
}

Verification Workflow

After the cross-chain message has been sent on the source chain (event has been emitted to notify DVNs and Executor), DVN will first verify the message on the destination chain, after which Executor will execute the message.

DVN Verification

DVNs call ReceiveUln302.verify to submit their witness of the source cross-chain message using the _payloadHash.

// LayerZero/V2/messagelib/contracts/uln/ReceiveUlnBase.sol
function verify(bytes calldata _packetHeader, bytes32 _payloadHash, uint64 _confirmations) external {
_verify(_packetHeader, _payloadHash, _confirmations);
}

mapping(bytes32 headerHash => mapping(bytes32 payloadHash => mapping(address dvn => Verification)))
public hashLookup;
function _verify(bytes calldata _packetHeader, bytes32 _payloadHash, uint64 _confirmations) internal {
hashLookup[keccak256(_packetHeader)][_payloadHash][msg.sender] = Verification(true, _confirmations);
emit PayloadVerified(msg.sender, _packetHeader, _confirmations, _payloadHash);
}

Commit Verification

After the OApp's required DVNs have all verified, and the threshold of optional DVNs has been reached, ReceiveUln302.commitVerification can be called by any address to commit the verification to the Endpoint's message channel.

// LayerZero/V2/messagelib/contracts/uln/uln302/ReceiveUln302.sol

struct UlnConfig {
uint64 confirmations;
// we store the length of required DVNs and optional DVNs instead of using DVN.length directly to save gas
uint8 requiredDVNCount; // 0 indicate DEFAULT, NIL_DVN_COUNT indicate NONE (to override the value of default)
uint8 optionalDVNCount; // 0 indicate DEFAULT, NIL_DVN_COUNT indicate NONE (to override the value of default)
uint8 optionalDVNThreshold; // (0, optionalDVNCount]
address[] requiredDVNs; // no duplicates. sorted an an ascending order. allowed overlap with optionalDVNs
address[] optionalDVNs; // no duplicates. sorted an an ascending order. allowed overlap with requiredDVNs
}

/// @dev dont need to check endpoint verifiable here to save gas, as it will reverts if not verifiable.
function commitVerification(bytes calldata _packetHeader, bytes32 _payloadHash) external {
// check packet header validity
_assertHeader(_packetHeader, localEid);

// decode the receiver and source Endpoint Id
address receiver = _packetHeader.receiverB20();
uint32 srcEid = _packetHeader.srcEid();

// get receiver's config
UlnConfig memory config = getUlnConfig(receiver, srcEid);
_verifyAndReclaimStorage(config, keccak256(_packetHeader), _payloadHash);

Origin memory origin = Origin(srcEid, _packetHeader.sender(), _packetHeader.nonce());

// call endpoint to verify payload hash
// endpoint will revert if nonce <= lazyInboundNonce
ILayerZeroEndpointV2(endpoint).verify(origin, receiver, _payloadHash);
}

function _assertHeader(bytes calldata _packetHeader, uint32 _localEid) internal pure {
// assert packet header is of right size 81
if (_packetHeader.length != 81) revert LZ_ULN_InvalidPacketHeader();
// assert packet header version is the same as ULN
if (_packetHeader.version() != PacketV1Codec.PACKET_VERSION) revert LZ_ULN_InvalidPacketVersion();
// assert the packet is for this endpoint
if (_packetHeader.dstEid() != _localEid) revert LZ_ULN_InvalidEid();
}

_verifyAndReclaimStorage verifies that the required and optional DVNs have submitted witness.

function _verifyAndReclaimStorage(UlnConfig memory _config, bytes32 _headerHash, bytes32 _payloadHash) internal {
if (!_checkVerifiable(_config, _headerHash, _payloadHash)) {
revert LZ_ULN_Verifying();
}

// iterate the required DVNs
if (_config.requiredDVNCount > 0) {
for (uint8 i = 0; i < _config.requiredDVNCount; ++i) {
delete hashLookup[_headerHash][_payloadHash][_config.requiredDVNs[i]];
}
}

// iterate the optional DVNs
if (_config.optionalDVNCount > 0) {
for (uint8 i = 0; i < _config.optionalDVNCount; ++i) {
delete hashLookup[_headerHash][_payloadHash][_config.optionalDVNs[i]];
}
}
}

Insert Hash to Endpoint's Message Channel

Inside the verify:

  • check msg.sender is valid ReceiveLibrary configured by the OApp.

  • get the lazyNonce of the OApp.

  • check the cross-chain message path is valid for the receiver.

  • check the message represented by the nonce has not been executed before.

  • insert the message into the Endpoint's message channel.

lazyNonce is the latest executed message’s nonce. To execute a transaction, LayerZero requires all messages before the current message has been verified. So all messages before the message with lazyNonce has been verified.

// LayerZero/V2/protocol/contracts/EndpointV2.sol

/// @dev configured receive library verifies a message
/// @param _origin a struct holding the srcEid, nonce, and sender of the message
/// @param _receiver the receiver of the message
/// @param _payloadHash the payload hash of the message
function verify(Origin calldata _origin, address _receiver, bytes32 _payloadHash) external {
// check msg.sender is valid ReceiveLibrary configured by the OApp
if (!isValidReceiveLibrary(_receiver, _origin.srcEid, msg.sender)) revert Errors.LZ_InvalidReceiveLibrary();

// get the lazynonce
uint64 lazyNonce = lazyInboundNonce[_receiver][_origin.srcEid][_origin.sender];

// check whether path is valid
if (!_initializable(_origin, _receiver, lazyNonce)) revert Errors.LZ_PathNotInitializable();

// check the nonce/msg hasn't been executed before
if (!_verifiable(_origin, _receiver, lazyNonce)) revert Errors.LZ_PathNotVerifiable();

// insert the message into the message channel
_inbound(_receiver, _origin.srcEid, _origin.sender, _origin.nonce, _payloadHash);
emit PacketVerified(_origin, _receiver, _payloadHash);
}

isValidReceiveLibrary checks whether the ReceiveLib is the expected ReceiveLib of the receiver. If not, then check whether there has been a Timeout set for the current ReceiveLib.

Timeout is used to help improve the UX of updating a ReceiveLib. For example, if OApp decides to switch the ReceiveLib, it can update the address on the destination chain, but some cross-chain messages may already be in-flight and not inserted in the destination chain Endpoint's message channel before the switch. Those messages depend on the previous ReceiveLib, so Timeout provides a grace period to ensure already in-flight messages have successful execution.

// LayerZero/V2/protocol/contracts/EndpointV2.sol

/// @dev called when the endpoint checks if the msgLib attempting to verify the msg is the configured msgLib of the Oapp
/// @dev this check provides the ability for Oapp to lock in a trusted msgLib
/// @dev it will fist check if the msgLib is the currently configured one. then check if the msgLib is the one in grace period of msgLib versioning upgrade
function isValidReceiveLibrary(
address _receiver,
uint32 _srcEid,
address _actualReceiveLib
) public view returns (bool) {
// early return true if the _actualReceiveLib is the currently configured one
(address expectedReceiveLib, bool isDefault) = getReceiveLibrary(_receiver, _srcEid);
if (_actualReceiveLib == expectedReceiveLib) {
return true;
}

// check the timeout condition otherwise
// if the Oapp is using defaultReceiveLibrary, use the default Timeout config
// otherwise, use the Timeout configured by the Oapp
Timeout memory timeout = isDefault
? defaultReceiveLibraryTimeout[_srcEid]
: receiveLibraryTimeout[_receiver][_srcEid];

// requires the _actualReceiveLib to be the same as the one in grace period and the grace period has not expired
// block.number is uint256 so timeout.expiry must > 0, which implies a non-ZERO value
if (timeout.lib == _actualReceiveLib && timeout.expiry > block.number) {
// timeout lib set and has not expired
return true;
}

// returns false by default
return false;
}

/// @dev the receiveLibrary can be lazily resolved that if not set it will point to the default configured by LayerZero
function getReceiveLibrary(address _receiver, uint32 _srcEid) public view returns (address lib, bool isDefault) {
lib = receiveLibrary[_receiver][_srcEid];
if (lib == DEFAULT_LIB) {
lib = defaultReceiveLibrary[_srcEid];
if (lib == address(0x0)) revert Errors.LZ_DefaultReceiveLibUnavailable();
isDefault = true;
}
}

_initializable is used to check whether the cross-chain message path is valid for the receiver. _lazyInboundNonce greater than 0 suggests a message has already been executed successfully, so no need to call _receiver to check the path again, which helps save gas.

Otherwise, call _receiver.allowInitializePath to check (the OApp standard inherits OAppReceiver which has already implemented allowInitializePath).

// LayerZero/V2/protocol/contracts/EndpointV2.sol

function _initializable(
Origin calldata _origin,
address _receiver,
uint64 _lazyInboundNonce
) internal view returns (bool) {
return
_lazyInboundNonce > 0 || // allowInitializePath already checked
ILayerZeroReceiver(_receiver).allowInitializePath(_origin);
}
// LayerZero/V2/oapp/contracts/oapp/OAppReceiver.sol

/**
* @notice Checks if the path initialization is allowed based on the provided origin.
* @param origin The origin information containing the source endpoint and sender address.
* @return Whether the path has been initialized.
*
* @dev This indicates to the endpoint that the OApp has enabled msgs for this particular path to be received.
* @dev This defaults to assuming if a peer has been set, its initialized.
* Can be overridden by the OApp if there is other logic to determine this.
*/
function allowInitializePath(Origin calldata origin) public view virtual returns (bool) {
return peers[origin.srcEid] == origin.sender;
}

_verifiable checks that the nonce / message has not been executed before.

  • If _origin.nonce > _lazyInboundNonce, then the nonce / message has not been executed before, otherwise _lazyInboundNonce_origin.nonce.

  • If _origin.nonce_lazyInboundNonce, then the nonce / message has been verified. If the payload hash is empty, which means the nonce / message has been executed (because the Endpoint will clear the payload hash of the nonce after successful execution), it cannot be executed again.

// LayerZero/V2/protocol/contracts/EndpointV2.sol
function _verifiable(
Origin calldata _origin,
address _receiver,
uint64 _lazyInboundNonce
) internal view returns (bool) {
return
_origin.nonce > _lazyInboundNonce || // either initializing an empty slot or reverifying
inboundPayloadHash[_receiver][_origin.srcEid][_origin.sender][_origin.nonce] != EMPTY_PAYLOAD_HASH; // only allow reverifying if it hasn't been executed
}

_inbound inserts the message into the channel (inboundPayloadHash).

// LayerZero/V2/protocol/contracts/MessagingChannel.sol

/// @dev inbound won't update the nonce eagerly to allow unordered verification
/// @dev instead, it will update the nonce lazily when the message is received
/// @dev messages can only be cleared in order to preserve censorship-resistance
function _inbound(
address _receiver,
uint32 _srcEid,
bytes32 _sender,
uint64 _nonce,
bytes32 _payloadHash
) internal {
if (_payloadHash == EMPTY_PAYLOAD_HASH) revert Errors.LZ_InvalidPayloadHash();
inboundPayloadHash[_receiver][_srcEid][_sender][_nonce] = _payloadHash;
}

Receive Workflow

Endpoint Execution

After the cross-chain message has been inserted into the channel (Endpoint.inboundPayloadHash), Executor will try to call Endpoint.lzReceive to execute the message.

  • clear the payload first to prevent reentrancy and double execution.

  • call ILayerZeroReceiver.lzReceive to execute the message.

// LayerZero/V2/protocol/contracts/EndpointV2.sol

struct Origin {
uint32 srcEid;
bytes32 sender;
uint64 nonce;
}

/// @dev execute a verified message to the designated receiver
/// @dev the execution provides the execution context (caller, extraData) to the receiver. the receiver can optionally assert the caller and validate the untrusted extraData
/// @dev cant reentrant because the payload is cleared before execution
/// @param _origin the origin of the message
/// @param _receiver the receiver of the message
/// @param _guid the guid of the message
/// @param _message the message
/// @param _extraData the extra data provided by the executor. this data is untrusted and should be validated.
function lzReceive(
Origin calldata _origin,
address _receiver,
bytes32 _guid,
bytes calldata _message,
bytes calldata _extraData
) external payable {
// clear the payload first to prevent reentrancy, and then execute the message
_clearPayload(_receiver, _origin.srcEid, _origin.sender, _origin.nonce, abi.encodePacked(_guid, _message));
ILayerZeroReceiver(_receiver).lzReceive{ value: msg.value }(_origin, _guid, _message, msg.sender, _extraData);
emit PacketDelivered(_origin, _receiver);
}

Inside the _clearPayload:

  • update the lazyInboundNonce.

  • verify payload provided by Executor.

  • delete message in the channel to prevent double execution.

// LayerZero/V2/protocol/contracts/EndpointV2.sol


/// @dev calling this function will clear the stored message and increment the lazyInboundNonce to the provided nonce
/// @dev if a lot of messages are queued, the messages can be cleared with a smaller step size to prevent OOG
/// @dev NOTE: this function does not change inboundNonce, it only changes the lazyInboundNonce up to the provided nonce
function _clearPayload(
address _receiver,
uint32 _srcEid,
bytes32 _sender,
uint64 _nonce,
bytes memory _payload
) internal returns (bytes32 actualHash) {
uint64 currentNonce = lazyInboundNonce[_receiver][_srcEid][_sender];
if (_nonce > currentNonce) {
unchecked {
// try to lazily update the inboundNonce till the _nonce
for (uint64 i = currentNonce + 1; i <= _nonce; ++i) {
if (!_hasPayloadHash(_receiver, _srcEid, _sender, i)) revert Errors.LZ_InvalidNonce(i);
}
lazyInboundNonce[_receiver][_srcEid][_sender] = _nonce;
}
}

// check the hash of the payload to verify the executor has given the proper payload that has been verified
actualHash = keccak256(_payload);
bytes32 expectedHash = inboundPayloadHash[_receiver][_srcEid][_sender][_nonce];
if (expectedHash != actualHash) revert Errors.LZ_PayloadHashNotFound(expectedHash, actualHash);

// remove it from the storage
delete inboundPayloadHash[_receiver][_srcEid][_sender][_nonce];
}

OApp Execution

By default, the OApp standard inherits OAppReceiver which implements lzReceive called by Endpoint to execute message.

  • check msg.sender is Endpoint.

  • check the path is valid.

  • call internal _lzReceive to execute logic (developer should override to add specific use).

// LayerZero/V2/oapp/contracts/oapp/OAppReceiver.sol

/**
* @dev Entry point for receiving messages or packets from the endpoint.
* @param _origin The origin information containing the source endpoint and sender address.
* - srcEid: The source chain endpoint ID.
* - sender: The sender address on the src chain.
* - nonce: The nonce of the message.
* @param _guid The unique identifier for the received LayerZero message.
* @param _message The payload of the received message.
* @param _executor The address of the executor for the received message.
* @param _extraData Additional arbitrary data provided by the corresponding executor.
*
* @dev Entry point for receiving msg/packet from the LayerZero endpoint.
*/
function lzReceive(
Origin calldata _origin,
bytes32 _guid,
bytes calldata _message,
address _executor,
bytes calldata _extraData
) public payable virtual {
// Ensures that only the endpoint can attempt to lzReceive() messages to this OApp.
if (address(endpoint) != msg.sender) revert OnlyEndpoint(msg.sender);

// Ensure that the sender matches the expected peer for the source endpoint.
if (_getPeerOrRevert(_origin.srcEid) != _origin.sender) revert OnlyPeer(_origin.srcEid, _origin.sender);

// Call the internal OApp implementation of lzReceive.
_lzReceive(_origin, _guid, _message, _executor, _extraData);
}

/**
* @dev Internal function to implement lzReceive logic without needing to copy the basic parameter validation.
*/
function _lzReceive(
Origin calldata _origin,
bytes32 _guid,
bytes calldata _message,
address _executor,
bytes calldata _extraData
) internal virtual;

In the original _getPeerOrRevert implementation, it can only assign one valid sender for each source chain, but developers can override this to allow multiple senders on one source chain.

// LayerZero/V2/oapp/contracts/oapp/OAppCore.sol

/**
* @notice Internal function to get the peer address associated with a specific endpoint; reverts if NOT set.
* ie. the peer is set to bytes32(0).
* @param _eid The endpoint ID.
* @return peer The address of the peer associated with the specified endpoint.
*/
function _getPeerOrRevert(uint32 _eid) internal view virtual returns (bytes32) {
bytes32 peer = peers[_eid];
if (peer == bytes32(0)) revert NoPeer(_eid);
return peer;
}

info

Developers should also override OAppReceiver.allowInitializePath so that the message can be successfully inserted into the Endpoint's message channel (the Endpoint will call to check whether the path is valid).

tip

Special thanks to community member SennHanami for their contribution to this documentation page. You can read their full deep-dive at: Decode LayerZero V2.