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.
Message Workflow
A user calls the
OApp
on the source chain and pays a fee to send a cross-chain message to theEndpoint
.The
Endpoint
check the validity of the cross-chain message and assigns each job to theOApp
configuredDVNs
(Decentralized Verifier Networks) andExecutor
to execute the cross-chain message.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 theEndpoint
on the destination chain.After the message has been inserted in the Endpoint's message channel, the
Executor
callsEndpoint.lzReceive
to trigger the execution of the cross-chain message on the destination chain.The
Endpoint
calls the payableReceiverOApp.lzReceive
to pass the message and execute the internal receive logic. You can modify the internal execution logic insideReceiverOApp._lzReceive
to trigger any intended outcome from the cross-chain message.
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
andExecutor
according to theOApp
send configuration for the cross-chain message. Also calculate and record the fee that should be paid to eachDVN
andExecutor
.check whether the fees the user is willing to pay can cover the fees required by the
DVNs
andExecutor
.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 notifyExecutor
andDVNs
, also calculate and record thefee
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
andExecutor
) and treasury. In the send process, thefee
is not directly paid to the workers, but recorded in the send library (SendUln302.sol
) for workers to claim later.call
DVNs
andExecutor
'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
) andvalidationOptions
(DVN
).get the
OApp
setExecutor
and correspondingmaxMessageSize
(If not set, then a defaultmaxMessageSize
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 correspondingExecutor
and record the fee paid.calls
_payVerifier
to assign job to specifiedDVNs
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
andpayload
, which will be used to emit event to notifyDVN
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 theguid
and the body of the cross-chain message.
get the sender
OApp
config about whichDVNs
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 allDVNs
.
// 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 validReceiveLibrary
configured by theOApp
.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 theEndpoint
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
isEndpoint
.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;
}
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).
Special thanks to community member SennHanami for their contribution to this documentation page. You can read their full deep-dive at: Decode LayerZero V2.