Decode LayerZero V2

Decode LayerZero V2

Tags
Web3
Cross-chain
Published
March 30, 2024
Author
Senn

Overview

LayerZero V2 has several significant upgrades compared to LayerZero V1.
  • Customizable security stack.
  • Selectable ordered or unordered message delivery.
  • Seperation between message verification and execution.
  • Parallel message execution.
  • Composed message.
If you are not familiar with LayerZero V1, you can read this blog, which will help you understand LayerZero V2 better: LayerZero V1 Blog.

Upgrades

Customizable security stack

In LayerZero V1, the security stack is fixed. For example, if we have two products, one needs high security level, one needs low security level. If we use layerzero V1 to implement those two products, we can’t select suitable security level for each. LayerZero V2 allows developers to select security level:
  • high security level means higher security, higer cost, longer x-chain message delivery time.
  • low security level means lower security, low cost, less x-chain message delivery time.
Developer can choose security stack at each product’s needs.

Ordered or unordered message delivery.

In LayerZero V1, message should always be delivered in order. Ordered Message Delivery is important in some senarios. For example, wants to send a x-chain message to accept some asset from another account, then transfers the asset to another account. These two messages should be executed orderly otherwise the logic will fail.
But in some senarios, we don’t need Ordered Message Delivery. Rather we may want to the x-chain messages to be delivered as quick as possible. In those scenarios, we wish to use unordered message delivery, so that different messages can be executed simultaneously.
LayerZero V2 allows developer to set the x-chain message delivery type, whether ordered or unordered, and Executor will execute message according to the setting.

Seperation between message verification and execution.

In LayerZero V1, the Relayer handled both the verification and execution of messages. In LayerZero V2, the verification is handled by the Security Stack, and execution by Executors. Developers can even choose to use their own executor to decide the execution time of the x-chain message.

Composed message.

LayerZero V2 supports composed message. Developer can use LayerZero V2 to send one x-chain message to register multiple message to be executed in different contracts on destination chain. We will dive deep into this later.
 

Architecture & Process

notion image
 
notion image
Process:
  1. OApp on source chain pays the fee and sends x-chain message to Endpoint
  1. Endpoint check the validity of the x-chain message and emit event to notify DVN(Decentralized Verifier Networks) and Executor to execute the x-chain message.
  1. DVN s verify the message on the destination chain. After the required and optional DVNs have verified the message, the message is to be inserted in the message channel of Endpoint on the destination chain.
  1. After message has been inserted in the message Endpoint's channel, Executor calls Endpoint.lzReceive to trigger the execution of the x-chain message.
  1. Endpoint calls ReceiverOApp.lzReceive to pass message and execute logic.

Contracts

ChainId/Network
contract
address
1/Ethereum
Default Executor
1/Ethereum
Endpoint
1/Ethereum
SendUln302
1/Ethereum
Executor PriceFeed
1/Ethereum
ReceiveUln302

Send

OApp calls EndpointV2.send to send x-chain message and pays the fee.
Inside the send:
  • emit event to each DVN and Executor according OApp’s config to send x-chain message. Also calculate and record fee should be paied to each DVN and Executor.
  • check whether the fees the user is willing to pay can cover the fees required by the protocol
  • 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; // x-chain message bytes options; // setting of executor and dvn bool payInLzToken; // whether pay in layerzero } struct MessagingReceipt { bytes32 guid; uint64 nonce; MessagingFee fee; } /// @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 _send:
  • 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 DVN , also calculate and record the fee should paied to them.
// 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); }
 
generate guid
// 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.send :
  • pay workers(DVN and Executor) and treasury. In fact in the send process, the fee is not directly paied to the workers, but recorded in send library(SendUln302.sol), workers can later claim those fees.
  • call DVN and Executor's contract to emit event to notify them to send x-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; } // @dev this function is marked as virtual and public for testing purpose function send( Packet calldata _packet, bytes calldata _options, bool _payInLzToken ) public virtual onlyEndpoint returns (MessagingFee memory, bytes memory) { // assign job to Executor and DVN, calcualte fees (bytes memory encodedPacket, uint256 totalNativeFee) = _payWorkers(_packet, _options); // calculate and pay the fee of treasury (uint256 treasuryNativeFee, uint256 lzTokenFee) = _payTreasury( _packet.sender, _packet.dstEid, totalNativeFee, _payInLzToken ); totalNativeFee += treasuryNativeFee; return (MessagingFee(totalNativeFee, lzTokenFee), encodedPacket); }
 
inside the SendUln302._payWorkers:
  • split options to get executorOptions and validationOptions(option of DVN)
  • get OApp’s set executor and corresponding maxMessageSize(If not set, then use default, current default maxMessageSize is 10000 bytes), check the size of the message to be sent is smaller than limitation
  • call _payExecutor to assign job to corresponding executor and record fee.
  • call _payVerifier to assign job to specified DVNs and record fee.
// 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 x-chain message.
    • payloadHash is a digest includes information of the version and path of the x-chain message;
    • payload includes information of the guid and the body of the x-chain message.
  • get user’s config about DVN
  • assign job for each DVN includes those 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 x-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 fee should be paied to the executor, and emit event to notify.
In the ExecutorFeeLib.getFeeOnSend, it will check the msg.value specified by the message sender should be smaller than the DstConfig.nativeCap of the destination chain.This is reasonable, because native token like ether is not controlled by OApp developer but the chain. Give EVM Layer2 as example, ether on Layer2 is controlled by the native bridge. Layerzero can only get limited ether through native bridge.
// 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; // msg.value 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 DVN

DVN.assignJob calls DVNFeeLib.getFeeOnSend to calculate fee should be paied to the DVNs, and emit event 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); }

Limitation

Max message bytes size

Message bytes size is limited on SendUln302 .
The default is 10000 bytes, but can be set per OApp.
// LayerZero/V2/messagelib/contracts/SendLibBaseE2.sol function _payWorkers( Packet calldata _packet, bytes calldata _options ) internal returns (bytes memory encodedPacket, uint256 totalNativeFee) { // [senn] emit for brevity... ExecutorConfig memory config = getExecutorConfig(_packet.sender, _packet.dstEid); uint256 msgSize = _packet.message.length; _assertMessageSize(msgSize, config.maxMessageSize); // [senn] emit for brevity... } 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; }

Max native token amount

In the ExecutorFeeLib._decodeExecutorOptions, it limits the maximal native token amount can be passed in destination chain transaction.
This config is set in Executor.dstConfig
// LayerZero/V2/messagelib/contracts/ExecutorFeeLib.sol function _decodeExecutorOptions( bool _v1Eid, uint64 _baseGas, uint128 _nativeCap, bytes calldata _options ) internal pure returns (uint256 dstAmount, uint256 totalGas) { // [senn] emit for brevity... if (dstAmount > _nativeCap) revert Executor_NativeAmountExceedsCap(dstAmount, _nativeCap); // [senn] emit for brevity... }
// 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; // maximal native token amount can be passed in destination chain transaction }

Verification

After the x-chain message has been sent on the local 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

Verify payload hash

DVNs calls ReceiveUln302.verify to submit their witness of x-chain message.
// 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 payload hash

After the OApp's required DVNs have all submitted witnesses, and the threshold of optional DVNs has been reached. ReceiveUln302.commitVerification can be called by any address to add commit the verification to notify the Endpoint.
// 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 the required and optional DVNs have submiited 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]]; } } }
 

Endpoint insert payload hash to message channel waiting for execution.

Inside the verify:
  • check msg.sender is valid ReceiveLibrary configured by the OApp
  • get the lazynonce of the OApp
  • check the x-chain message path is valid for the receiver
  • check the message represented by the nonce hasn’t been executed before
  • insert the message in channel
lazynonce is the latest’ executed message’s nonce. To execute a transaction, layerzero requries all message 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 is timeout setting for the current ReceiveLib.
Timeout is used to smooth the switch of ReceiveLib. For example, if OApp decideds to switch ReceiveLib, it can update it on the destination chain, but there may be x-chain messages not inserted into destionation chain Endpoint message channel before the switch, those messages depend on previous ReceiveLib . So Timeout provides grace period which is used to ensure previous messages’ success 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 x-chain message path is valid for the receiver. _lazyInboundNonce >0 suggests there is 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. OApp inherits OAppReceiver which has implemented allowInitializePath).
But there is potential problem, because OApp itself will check the path when called by Endpoint to execute message. If OApp changes path validity, then even message has been successfully inserted into the channel, it doesn’t ensure that it can be executed successfully by Receiverlater. And because there may be previous nonce have been successfully executed during time when the old path is still valid, later when OApp has changed path, the check of _initializable is not useful anymore.
// 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 is to check the nonce/message hasn’t been executed before.
  • If _origin.nonce > _lazyInboundNonce, then the nonce/message hasn’t 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 when message has been successfully executed, Endpoint will delete the payload hash of the nonce), it cant be executed again. If not, then it is allowed to ReceiveLib to update the payload of the nonce/message.
// 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 insert the message into 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

Endpoint verify message

After x-chain message has been inserted into the channel(Endpoint.inboundPayloadHash), Executor will call Endpoint.lzReceive to execute the message.
  • clear the payload first to prevent reentrancy and double execution
  • call Receiver.lzReceive to execute 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 executes message

OApp inherits OAppReceiver which implements lzReceive called by Endpoint to execute message.
  • check msg.sender is Endpoint .
  • check the path is valid.
  • call _lzReceive to execute logic(should be implemented by developer).
// 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); }
 
In the original _getPeerOrRevert implementation, it can only assign one valid sender of each source chain. But develper can overide it to allow multiple sender on one source chain. Note developer should also overide OAppReceiver.allowInitializePath so that message can be successfully inserted into Endpoint’s channel.(Endpoint will call it to check whether the path is valid).
// 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; }
 

Parallel message execution

LayerZero V1 requires x-chain message be executed orderly. But LayerZero V2 allows developer to choose whether restrict the order of message delivery. Unordered message delivery can increase delivery throughput, useful for protocols has no limitation on the message execution order.
 
But the parallel message execution in LayerZero V2 is also limited. For a message to be executed, it is required that the former messages after the lazyInboundNonce has all be verified.
So to be precise, parallel message execution only works on verified messages. Theoretically, we can implement fully parallell message execution.
// LayerZero/V2/protocol/contracts/EndpointV2.sol 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); } /// @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; } } // [senn] omit for brevity... }

Ordered Message

LayerZero allows OApp to enforce ordered message execution. This is important for some protocol which requires the order of message execution
To implement strict nonce enforcement, you need to implement the following:
  • a boolean flag orderedNonce, setting it to true in the constructor.
  • a mapping to track the maximum received nonce.
  • override _acceptNonce and nextNonce.
  • add ExecutorOrderedExecutionOption in _options when calling _lzSend.
_options = OptionsBuilder.newOptions().addExecutorLzReceiveOption(200000, 0).addExecutorOrderedExecutionOption();
 
orderedNonce and nextNonce can be called by Executor to decide which message to execute next. But this restriction is soft, to ensure the order of message, OApp should check the message execution order themselves.
 
Here is a basic demo.
// track maximal nonce received mapping(uint32 srcEid => mapping(bytes32 sender => uint64 nonce)) private maxReceivedNonce; // flag tells Executor whether message should be ordered bool private orderedNonce; // called by Executor to decide the next message to deliver function nextNonce(uint32 _srcEid, bytes32 _sender) public view virtual override returns (uint64) { if (orderedNonce) { return maxReceivedNonce[_srcEid][_sender] + 1; } else { return 0; // path nonce starts from 1. if 0 it means that there is no specific nonce enforcement } } // check the nonce received is the next nonce function _acceptNonce(uint32 _srcEid, bytes32 _sender, uint64 _nonce) internal virtual { uint64 currentNonce = maxReceivedNonce[_srcEid][_sender]; if (orderedNonce) { require(_nonce == currentNonce + 1, "OApp: invalid nonce"); } // update the max nonce anyway. once the ordered mode is turned on, missing early nonces will be rejected if (_nonce > currentNonce) { maxReceivedNonce[_srcEid][_sender] = _nonce; } } function _lzReceive( Origin calldata _origin, bytes32 _guid, bytes calldata _message, address /*_executor*/, bytes calldata /*_extraData*/ ) internal override { // ensure the order of message _acceptNonce(_origin.srcEid, _origin.sender, _origin.nonce); // [senn] omit for brevity... }
 
 

Compose message

LayerZero supports composed message. This pattern improve the flexibility of message execution on destination chain.
This pattern demonstrates horizontal composability, which differs from vertical composability in that the external call is now containerized as a new message packet; enabling the application to ensure that certain receipt logic remains separate from the external call itself.
notion image
 
The process:
  1. OApp set options for composed message
  1. during the message execution in OApp.lzReceive, OApp calls Endpoint.sendCompose to register composed message should be executed.
  1. Executor calls Endpoint.lzCompose to erigger execution of composed message.
  1. Endpoint calls receiver.lzCompose to execute composed message.
 
set options for composed message, specify each composed message’s index, gas and native token amount.
// LayerZero/V2/oapp/contracts/oapp/libs/OptionsBuilder.sol // addExecutorLzComposeOption(uint16 _index, uint128 _gas, uint128 _value) Options.newOptions() .addExecutorLzReceiveOption(50000, 0) .addExecutorLzComposeOption(0, 30000, 0) .addExecutorLzComposeOption(1, 30000, 0);
 
calls Endpoint.sendCompose to register composed message in the OApp.lzReceive
// LayerZero/V2/protocol/contracts/EndpointV2.sol mapping(address from => mapping(address to => mapping(bytes32 guid => mapping(uint16 index => bytes32 messageHash)))) public composeQueue; /// @dev the Oapp sends the lzCompose message to the endpoint /// @dev the composer MUST assert the sender because anyone can send compose msg with this function /// @dev with the same GUID, the Oapp can send compose to multiple _composer at the same time /// @dev authenticated by the msg.sender /// @param _to the address which will receive the composed message /// @param _guid the message guid /// @param _message the message function sendCompose(address _to, bytes32 _guid, uint16 _index, bytes calldata _message) external { // must have not been sent before if (composeQueue[msg.sender][_to][_guid][_index] != NO_MESSAGE_HASH) revert Errors.LZ_ComposeExists(); composeQueue[msg.sender][_to][_guid][_index] = keccak256(_message); emit ComposeSent(msg.sender, _to, _guid, _index, _message); }
 
Executor calls Endpoint.lzCompose to execute composed message.
/// @dev execute a composed messages from the sender to the composer (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 can not re-entrant /// @param _from the address which sends the composed message. in most cases, it is the Oapp's address. /// @param _to the address which receives the composed message /// @param _guid the message guid /// @param _message the message /// @param _extraData the extra data provided by the executor. this data is untrusted and should be validated. function lzCompose( address _from, address _to, bytes32 _guid, uint16 _index, bytes calldata _message, bytes calldata _extraData ) external payable { // assert the validity bytes32 expectedHash = composeQueue[_from][_to][_guid][_index]; bytes32 actualHash = keccak256(_message); if (expectedHash != actualHash) revert Errors.LZ_ComposeNotFound(expectedHash, actualHash); // marks the message as received to prevent reentrancy // cannot just delete the value, otherwise the message can be sent again and could result in some undefined behaviour // even though the sender(composing Oapp) is implicitly fully trusted by the composer. // eg. sender may not even realize it has such a bug composeQueue[_from][_to][_guid][_index] = RECEIVED_MESSAGE_HASH; ILayerZeroComposer(_to).lzCompose{ value: msg.value }(_from, _guid, _message, msg.sender, _extraData); emit ComposeDelivered(_from, _to, _guid, _index); }
 

Advantage

This pattern provides a framework to handle complex logics execution on destination chain.
 
For example, assume we want to implement a multichain vote system, this system allows user to send a vote on arbitrary chain, and update the votes on one single chain.
Alice can send transaction on chain which sends x-chain message to update the vote on the contract on chain .
We want add new functionality, like issuing SBT to voters to reward them after they have voted. Using LayerZero V1, we need to encapsulate the this NFT issue logic into the OApp.lzReceive, encode necessary information in the message, decode it, then call another SBT contract to issue the SBT. If we want to issue more kinds of SBTs to the user or do some other operations, we need add corresponding logic in OApp.lzReceive . This is workable, but LayerZero V2 help us abstract and simplify this process.
With LayerZero V2, OApp can call Endpoint to register other packet to be executed, wait to be executed by Executor separately.

Options

options are used by OApp to control Executor or DVN to execute specify specific logic of the x-chain message execution. Like native token drop, gas amount, orderding of messages on the destination chain.
LayerZero provides a solidity library and off-chain SDK for developers to build specific Message Options.
 
When calling Endpoint to send x-chain message, OApp can pass the options with type bytes.
// LayerZero/V2/protocol/contracts/EndpointV2.sol struct MessagingParams { uint32 dstEid; bytes32 receiver; bytes message; bytes options; bool payInLzToken; } function send( MessagingParams calldata _params, address _refundAddress ) external payable sendContext(_params.dstEid, msg.sender) returns (MessagingReceipt memory) { // [senn] omit for brevity... }
 
In the SendUln302._payWorkers, it will use _splitOptions to split options to get options for Executor and DVNs, then call _payExecutor and _payVerifier to pass corresponding options to calculate fees needed.
// LayerZero/V2/messagelib/contracts/uln/uln302/SendUln302.sol 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; }

Option construction

OptionsBuilder.sol is a solidity library helps to build options. Below is a snippet about how to use OptionsBuilder.sol to builr options.
import { OptionsBuilder } from "@layerzerolabs/lz-evm-oapp-v2/contracts/oapp/libs/OptionsBuilder.sol"; contract Demo { using OptionsBuilder for bytes; bytes memory options = OptionsBuilder.newOptions().addExecutorLzReceiveOption(50000, 0); }
 
options's first 2 bytes represents the version type of the option.
// LayerZero/V2/oapp/contracts/oapp/libs/OptionsBuilder.sol uint16 internal constant TYPE_1 = 1; // legacy options type 1 uint16 internal constant TYPE_2 = 2; // legacy options type 2 uint16 internal constant TYPE_3 = 3; /** * @dev Creates a new options container with type 3. * @return options The newly created options container. */ function newOptions() internal pure returns (bytes memory) { return abi.encodePacked(TYPE_3); }
There are three option version types.
TYPE_1 and TYPE_2 are used for back-compatibility, which acts as adapterParams in LayerZero V1.
TYPE_3 represents LayerZero V2’s option system.

Option type

LzReceiveOption

LzReceiveOption is used to tell Executor the gas and native token amount used when call the Endpoint.lzReceive.
// LayerZero/V2/oapp/contracts/oapp/libs/OptionsBuilder.sol /** * @dev Adds an executor LZ receive option to the existing options. * @param _options The existing options container. * @param _gas The gasLimit used on the lzReceive() function in the OApp. * @param _value The msg.value passed to the lzReceive() function in the OApp. * @return options The updated options container. * * @dev When multiples of this option are added, they are summed by the executor * eg. if (_gas: 200k, and _value: 1 ether) AND (_gas: 100k, _value: 0.5 ether) are sent in an option to the LayerZeroEndpoint, * that becomes (300k, 1.5 ether) when the message is executed on the remote lzReceive() function. */ function addExecutorLzReceiveOption( bytes memory _options, uint128 _gas, uint128 _value ) internal pure onlyType3(_options) returns (bytes memory) { bytes memory option = ExecutorOptions.encodeLzReceiveOption(_gas, _value); return addExecutorOption(_options, ExecutorOptions.OPTION_TYPE_LZRECEIVE, option); }

NativeDropOption

This option contains how much native gas sender wants to drop to the _receiver, this is often done to allow users or a contract to have some gas on a new chain.
/** * @dev Adds an executor native drop option to the existing options. * @param _options The existing options container. * @param _amount The amount for the native value that is airdropped to the 'receiver'. * @param _receiver The receiver address for the native drop option. * @return options The updated options container. * * @dev When multiples of this option are added, they are summed by the executor on the remote chain. */ function addExecutorNativeDropOption( bytes memory _options, uint128 _amount, bytes32 _receiver ) internal pure onlyType3(_options) returns (bytes memory) { bytes memory option = ExecutorOptions.encodeNativeDropOption(_amount, _receiver); return addExecutorOption(_options, ExecutorOptions.OPTION_TYPE_NATIVE_DROP, option); }

LzComposeOption

Control the gas amount and native token amount of each composed message executed by the Executor on the destination chain.
/** * @dev Adds an executor LZ compose option to the existing options. * @param _options The existing options container. * @param _index The index for the lzCompose() function call. * @param _gas The gasLimit for the lzCompose() function call. * @param _value The msg.value for the lzCompose() function call. * @return options The updated options container. * * @dev When multiples of this option are added, they are summed PER index by the executor on the remote chain. * @dev If the OApp sends N lzCompose calls on the remote, you must provide N incremented indexes starting with 0. * ie. When your remote OApp composes (N = 3) messages, you must set this option for index 0,1,2 */ function addExecutorLzComposeOption( bytes memory _options, uint16 _index, uint128 _gas, uint128 _value ) internal pure onlyType3(_options) returns (bytes memory) { bytes memory option = ExecutorOptions.encodeLzComposeOption(_index, _gas, _value); return addExecutorOption(_options, ExecutorOptions.OPTION_TYPE_LZCOMPOSE, option); }

ExecutorOrderedExecutionOption

Control whether the x-chain messages should be ordered.
/** * @dev Adds an executor ordered execution option to the existing options. * @param _options The existing options container. * @return options The updated options container. */ function addExecutorOrderedExecutionOption( bytes memory _options ) internal pure onlyType3(_options) returns (bytes memory) { return addExecutorOption(_options, ExecutorOptions.OPTION_TYPE_ORDERED_EXECUTION, bytes("")); }

DVNPreCrimeOption

/** * @dev Adds a DVN pre-crime option to the existing options. * @param _options The existing options container. * @param _dvnIdx The DVN index for the pre-crime option. * @return options The updated options container. */ function addDVNPreCrimeOption( bytes memory _options, uint8 _dvnIdx ) internal pure onlyType3(_options) returns (bytes memory) { return addDVNOption(_options, _dvnIdx, DVNOptions.OPTION_TYPE_PRECRIME, bytes("")); }

Multiple sender in one source chain

Developer should override both OAppCore._getPeerOrRevert and OAppReceiver.allowInitializePath to allow multiple sender in one source chain.
  • OAppCore._getPeerOrRevert is called in OApp.lzReceive#_getPeerOrRevert to check message path validity.
  • OAppReceiver.allowInitializePath is called in Endpoint.verify#_initializable to check path validity for message to be successfully inserted into channel.
// 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; }
// 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; }

Fee

Fee is an important concept in LayerZero. x-chain message sender should pay fee to Executor and DVN to execute transaction on destination chain.
Since it’s a message between blockchains, on the local chain, user can’t pay the fee using token on the destination chain directly. So LayerZero convert the fee needed in unit of local token. For example, if user wants to send message from Ethereum to Solana, user can pay using ether rather sol.

Executor Fee

Executor stores config about the destination chain. It calls ExecutorFeeLib.getFeeOnSend to calculate total fee needed in the unit of native token of local chain.
// 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; } uint16 public defaultMultiplierBps; 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); }
 
Inside the ExecutorFeeLib.getFeeOnSend :
  • calculate gas needed for the x-chain message, considering base gas, transaction gas and ordering fee, in the unit of native token of the destination chain.
  • calculate additional fee according to destination chain’s setting, like calldata post fee of layer2. calculate the fee in the unit of the native token of current chain.
  • ajust the fee according to premium(percentage or fixed usd margin).
  • add the native token passed in destination chain transaction into fee.
// LayerZero/V2/messagelib/contracts/ExecutorFeeLib.sol function getFeeOnSend( FeeParams calldata _params, IExecutor.DstConfig calldata _dstConfig, bytes calldata _options ) external returns (uint256 fee) { if (_dstConfig.baseGas == 0) revert Executor_EidNotSupported(_params.dstEid); // calculate gas needed for the x-chain message // considering base gas, transaction gas and ordering fee // in the unit of native token of the destination chain. (uint256 totalDstAmount, uint256 totalGas) = _decodeExecutorOptions( _isV1Eid(_params.dstEid), _dstConfig.baseGas, _dstConfig.nativeCap, _options ); // for future versions where priceFeed charges a fee // calculate additional fee according to destination chain’s setting, // like calldata post fee of layer2. // calculate the fee in the unit of the native token of current chain. ( uint256 totalGasFee, uint128 priceRatio, uint128 priceRatioDenominator, uint128 nativePriceUSD ) = ILayerZeroPriceFeed(_params.priceFeed).estimateFeeOnSend(_params.dstEid, _params.calldataSize, totalGas); // ajust the fee according to premium(percentage or fixed usd margin). fee = _applyPremiumToGas( totalGasFee, _dstConfig.multiplierBps, _params.defaultMultiplierBps, _dstConfig.floorMarginUSD, nativePriceUSD ); // add the native token passed in destination chain transaction into fee. fee += _convertAndApplyPremiumToValue( totalDstAmount, priceRatio, priceRatioDenominator, _params.defaultMultiplierBps ); }
 

Base fee

ExecutorFeeLib._decodeExecutorOptions decodes the options for executor, and calcualte needed.
Gas Fee Structure
  • Base Gas
    • Gas used by executor to verify message, and the overhead of the fixed part of calldata (like _origin, _receiver and _guid)
  • Transaction gas specified in option
    • gasLimit specified in LzReceiveOption and LzComposeOption
  • Ordering fee
    • If the x-chain message is set to be ordered, there is additional 2% fee. I guess this is to cover the additional cost of the executor service due to the need to monitor the status of the x-chain message channel to ensure the ordered delivery.
It also checks that the native token passed in the destination chain is smaller than the cap set in the Executor.dstConfig
// LayerZero/V2/messagelib/contracts/ExecutorFeeLib.sol // @dev decode executor options into dstAmount and totalGas function _decodeExecutorOptions( bool _v1Eid, uint64 _baseGas, uint128 _nativeCap, bytes calldata _options ) internal pure returns (uint256 dstAmount, uint256 totalGas) { if (_options.length == 0) { revert Executor_NoOptions(); } uint256 cursor = 0; bool ordered = false; totalGas = _baseGas; uint256 lzReceiveGas; while (cursor < _options.length) { (uint8 optionType, bytes calldata option, uint256 newCursor) = _options.nextExecutorOption(cursor); cursor = newCursor; if (optionType == ExecutorOptions.OPTION_TYPE_LZRECEIVE) { (uint128 gas, uint128 value) = ExecutorOptions.decodeLzReceiveOption(option); // endpoint v1 does not support lzReceive with value if (_v1Eid && value > 0) revert Executor_UnsupportedOptionType(optionType); dstAmount += value; lzReceiveGas += gas; } else if (optionType == ExecutorOptions.OPTION_TYPE_NATIVE_DROP) { (uint128 nativeDropAmount, ) = ExecutorOptions.decodeNativeDropOption(option); dstAmount += nativeDropAmount; } else if (optionType == ExecutorOptions.OPTION_TYPE_LZCOMPOSE) { // endpoint v1 does not support lzCompose if (_v1Eid) revert Executor_UnsupportedOptionType(optionType); (, uint128 gas, uint128 value) = ExecutorOptions.decodeLzComposeOption(option); dstAmount += value; totalGas += gas; } else if (optionType == ExecutorOptions.OPTION_TYPE_ORDERED_EXECUTION) { ordered = true; } else { revert Executor_UnsupportedOptionType(optionType); } } if (cursor != _options.length) revert Executor_InvalidExecutorOptions(cursor); // check the native token passed in the destination chain is smaller than the cap if (dstAmount > _nativeCap) revert Executor_NativeAmountExceedsCap(dstAmount, _nativeCap); if (lzReceiveGas == 0) revert Executor_ZeroLzReceiveGasProvided(); totalGas += lzReceiveGas; // if message is ordered, charge additional fee if (ordered) { totalGas = (totalGas * 102) / 100; } }
 

Chain specific fee & convert unit to local chain native token

Inside the PriceFeed.estimateFeeOnSend:
  • calculate additional fee according to destination chain.
  • calculate the fee in unit of native token in local chain using price ratio of native tokens of local and destination chains.
// LayerZero/V2/messagelib/contracts/PriceFeed.sol function estimateFeeOnSend( uint32 _dstEid, uint256 _callDataSize, uint256 _gas ) external payable returns (uint256, uint128, uint128, uint128) { uint256 fee = getFee(_dstEid, _callDataSize, _gas); if (msg.value < fee) revert LZ_PriceFeed_InsufficientFee(msg.value, fee); return _estimateFeeByEid(_dstEid, _callDataSize, _gas); } // get fee for calling estimateFeeOnSend function getFee(uint32 /*_dstEid*/, uint256 /*_callDataSize*/, uint256 /*_gas*/) public pure returns (uint256) { return 0; } function _estimateFeeByEid( uint32 _dstEid, uint256 _callDataSize, uint256 _gas ) internal view returns (uint256 fee, uint128 priceRatio, uint128 priceRatioDenominator, uint128 priceUSD) { uint32 dstEid = _dstEid % 30_000; if (dstEid == 110 || dstEid == 10143 || dstEid == 20143) { (fee, priceRatio) = _estimateFeeWithArbitrumModel(dstEid, _callDataSize, _gas); } else if (dstEid == 111 || dstEid == 10132 || dstEid == 20132) { (fee, priceRatio) = _estimateFeeWithOptimismModel(dstEid, _callDataSize, _gas); } else { (fee, priceRatio) = _estimateFeeWithDefaultModel(dstEid, _callDataSize, _gas); } priceRatioDenominator = PRICE_RATIO_DENOMINATOR; priceUSD = _nativePriceUSD; }
 
Arbitrum Model
L1 gas
L2 gas
Calculate total gas
Convert the unit to native token of local chain
// LayerZero/V2/messagelib/contracts/PriceFeed.sol struct Price { uint128 priceRatio; // float value * 10 ^ 20, decimal awared. for aptos to evm, the basis would be (10^18 / 10^8) * 10 ^20 = 10 ^ 30. uint64 gasPriceInUnit; // for evm, it is in wei, for aptos, it is in octas. uint32 gasPerByte; } function _estimateFeeWithArbitrumModel( uint32 _dstEid, uint256 _callDataSize, uint256 _gas ) internal view returns (uint256 fee, uint128 priceRatio) { Price storage arbitrumPrice = _defaultModelPrice[_dstEid]; // L1 fee uint256 gasForL1CallData = ((_callDataSize * ARBITRUM_COMPRESSION_PERCENT) / 100) * _arbitrumPriceExt.gasPerL1CallDataByte; // L2 Fee uint256 gasForL2CallData = _callDataSize * arbitrumPrice.gasPerByte; uint256 gasFee = (_gas + _arbitrumPriceExt.gasPerL2Tx + gasForL1CallData + gasForL2CallData) * arbitrumPrice.gasPriceInUnit; return ((gasFee * arbitrumPrice.priceRatio) / PRICE_RATIO_DENOMINATOR, arbitrumPrice.priceRatio); }
 
Optimism Model
L1 gas
L2 gas
Convert unit to local chain native token
Calculate total fee
// LayerZero/V2/messagelib/contracts/PriceFeed.sol struct Price { uint128 priceRatio; // float value * 10 ^ 20, decimal awared. for aptos to evm, the basis would be (10^18 / 10^8) * 10 ^20 = 10 ^ 30. uint64 gasPriceInUnit; // for evm, it is in wei, for aptos, it is in octas. uint32 gasPerByte; } function _estimateFeeWithOptimismModel( uint32 _dstEid, uint256 _callDataSize, uint256 _gas ) internal view returns (uint256 fee, uint128 priceRatio) { uint32 ethereumId = _getL1LookupId(_dstEid); // L1 fee Price storage ethereumPrice = _defaultModelPrice[ethereumId]; uint256 gasForL1CallData = (_callDataSize * ethereumPrice.gasPerByte) + 3188; // 2100 + 68 * 16 uint256 l1Fee = gasForL1CallData * ethereumPrice.gasPriceInUnit; // L2 fee Price storage optimismPrice = _defaultModelPrice[_dstEid]; uint256 gasForL2CallData = _callDataSize * optimismPrice.gasPerByte; uint256 l2Fee = (gasForL2CallData + _gas) * optimismPrice.gasPriceInUnit; uint256 l1FeeInSrcPrice = (l1Fee * ethereumPrice.priceRatio) / PRICE_RATIO_DENOMINATOR; uint256 l2FeeInSrcPrice = (l2Fee * optimismPrice.priceRatio) / PRICE_RATIO_DENOMINATOR; uint256 gasFee = l1FeeInSrcPrice + l2FeeInSrcPrice; return (gasFee, optimismPrice.priceRatio); }
 
Default Model
Calldata gas
Total fee in unit of native token of destination chain
Convert unit to local chain native token
// LayerZero/V2/messagelib/contracts/PriceFeed.sol struct Price { uint128 priceRatio; // float value * 10 ^ 20, decimal awared. for aptos to evm, the basis would be (10^18 / 10^8) * 10 ^20 = 10 ^ 30. uint64 gasPriceInUnit; // for evm, it is in wei, for aptos, it is in octas. uint32 gasPerByte; } function _estimateFeeWithDefaultModel( uint32 _dstEid, uint256 _callDataSize, uint256 _gas ) internal view returns (uint256 fee, uint128 priceRatio) { Price storage remotePrice = _defaultModelPrice[_dstEid]; // assuming the _gas includes (1) the 21,000 overhead and (2) not the calldata gas uint256 gasForCallData = _callDataSize * remotePrice.gasPerByte; uint256 remoteFee = (gasForCallData + _gas) * remotePrice.gasPriceInUnit; return ((remoteFee * remotePrice.priceRatio) / PRICE_RATIO_DENOMINATOR, remotePrice.priceRatio); }
 

Premium fee

LayerZero can apply premium to fee. There are two ways to apply premium:
  • percentage
  • margin in USD value
LayerZero calculates both fee, and use the larger one.
function _applyPremiumToGas( uint256 _fee, uint16 _bps, uint16 _defaultBps, uint128 _marginUSD, uint128 _nativePriceUSD ) internal view returns (uint256) { uint16 multiplierBps = _bps == 0 ? _defaultBps : _bps; // calculate total fee based on percentage method uint256 feeWithMultiplier = (_fee * multiplierBps) / 10000; // to calcualte based on margin, should have margin's USD value, and usd price of native token if (_nativePriceUSD == 0 || _marginUSD == 0) { return feeWithMultiplier; } // calculate native token amount based on margin usd value and native token usd price uint256 feeWithMargin = (_marginUSD * nativeDecimalsRate) / _nativePriceUSD + _fee; // use the larger one as the final fee return feeWithMargin > feeWithMultiplier ? feeWithMargin : feeWithMultiplier; }
 

Native token passed on destination chain

calculates the local native token amount whose value equals the remote native token’s passed in the destination transaction.
Note here layerzero can apply multiplier to the calculated token amount.
// includes value and nativeDrop function _convertAndApplyPremiumToValue( uint256 _value, // amount of native token passed in destionation transaction uint128 _ratio, // local native token : remote native token uint128 _denom, // ratio's corresponding denominator(for decimal precision purpose) uint16 _defaultBps // multiplier ) internal pure returns (uint256 fee) { if (_value > 0) { fee = (((_value * _ratio) / _denom) * _defaultBps) / 10000; } }

DVN Fee

DVNFeeLib.getFeeOnSend :
  • first calculates calldata size of the DVN, and use pricefeed to calculate gas needed to cover the transaction of DVN on destination chain to verify message.
  • then apply premium.
// LayerZero/V2/messagelib/contracts/uln/dvn/DVNFeeLib.sol /// @dev get fee function that can change state. e.g. paying priceFeed /// @param _params fee params /// @param _dstConfig dst config /// @param //_options options function getFeeOnSend( FeeParams calldata _params, IDVN.DstConfig calldata _dstConfig, bytes calldata _options ) external payable returns (uint256) { if (_dstConfig.gas == 0) revert DVN_EidNotSupported(_params.dstEid); _decodeDVNOptions(_options); // todo: validate options uint256 callDataSize = _getCallDataSize(_params.quorum); // for future versions where priceFeed charges a fee // uint256 priceFeedFee = ILayerZeroPriceFeed(_params.priceFeed).getFee(_params.dstEid, callDataSize, _dstConfig.gas); // (uint256 fee, , , uint128 nativePriceUSD) = ILayerZeroPriceFeed(_params.priceFeed).estimateFeeOnSend{ // value: priceFeedFee // }(_params.dstEid, callDataSize, _dstConfig.gas); (uint256 fee, , , uint128 nativePriceUSD) = ILayerZeroPriceFeed(_params.priceFeed).estimateFeeOnSend( _params.dstEid, callDataSize, _dstConfig.gas ); return _applyPremium( fee, _dstConfig.multiplierBps, _params.defaultMultiplierBps, _dstConfig.floorMarginUSD, nativePriceUSD ); } function _getCallDataSize(uint256 _quorum) internal pure returns (uint256) { uint256 totalSignatureBytes = _quorum * SIGNATURE_RAW_BYTES; if (totalSignatureBytes % 32 != 0) { totalSignatureBytes = totalSignatureBytes - (totalSignatureBytes % 32) + 32; } // getFee should charge on execute(updateHash) // totalSignatureBytesPadded also has 64 overhead for bytes return uint256(EXECUTE_FIXED_BYTES) + UPDATE_HASH_BYTES + totalSignatureBytes + 64; }

Treasury Fee

// LayerZero/V2/messagelib/contracts/uln/uln302/SendUln302.sol function send( Packet calldata _packet, bytes calldata _options, bool _payInLzToken ) public virtual onlyEndpoint returns (MessagingFee memory, bytes memory) { (bytes memory encodedPacket, uint256 totalNativeFee) = _payWorkers(_packet, _options); (uint256 treasuryNativeFee, uint256 lzTokenFee) = _payTreasury( _packet.sender, _packet.dstEid, totalNativeFee, _payInLzToken ); totalNativeFee += treasuryNativeFee; return (MessagingFee(totalNativeFee, lzTokenFee), encodedPacket); } function _payTreasury( address _sender, uint32 _dstEid, uint256 _totalNativeFee, bool _payInLzToken ) internal returns (uint256 treasuryNativeFee, uint256 lzTokenFee) { if (treasury != address(0x0)) { bytes memory callData = abi.encodeCall( ILayerZeroTreasury.payFee, (_sender, _dstEid, _totalNativeFee, _payInLzToken) ); (bool success, bytes memory result) = treasury.safeCall(treasuryGasLimit, 0, TREASURY_MAX_COPY, callData); (treasuryNativeFee, lzTokenFee) = _parseTreasuryResult(_totalNativeFee, _payInLzToken, success, result); // fee should be in lzTokenFee if payInLzToken, otherwise in native if (treasuryNativeFee > 0) { fees[treasury] += treasuryNativeFee; } } } function _parseTreasuryResult( uint256 _totalNativeFee, bool _payInLzToken, bool _success, bytes memory _result ) internal view returns (uint256 nativeFee, uint256 lzTokenFee) { // failure, charges nothing if (!_success || _result.length < TREASURY_MAX_COPY) return (0, 0); // parse the result uint256 treasureFeeQuote = abi.decode(_result, (uint256)); if (_payInLzToken) { lzTokenFee = treasureFeeQuote; } else { // pay in native // we must prevent high-treasuryFee Dos attack // nativeFee = min(treasureFeeQuote, maxNativeFee) // opportunistically raise the maxNativeFee to be the same as _totalNativeFee // can't use the _totalNativeFee alone because the oapp can use custom workers to force the fee to 0. // maxNativeFee = max (_totalNativeFee, treasuryNativeFeeCap) uint256 maxNativeFee = _totalNativeFee > treasuryNativeFeeCap ? _totalNativeFee : treasuryNativeFeeCap; // min (treasureFeeQuote, nativeFeeCap) nativeFee = treasureFeeQuote > maxNativeFee ? maxNativeFee : treasureFeeQuote; } }
// LayerZero/V2/messagelib/contracts/Treasury.sol function payFee( address /*_sender*/, uint32 /*_eid*/, uint256 _totalFee, bool _payInLzToken ) external payable override returns (uint256) { return _getFee(_totalFee, _payInLzToken); } function _getFee(uint256 _totalFee, bool _payInLzToken) internal view returns (uint256) { if (_payInLzToken) { if (!lzTokenEnabled) revert LZ_Treasury_LzTokenNotEnabled(); return lzTokenFee; } else { return (_totalFee * nativeBP) / 10000; } }
 

Skip & Clear & Nilify

Skip

OApp can call EndpointV2.skip to skip the next unverified nonce which prevents the nonce from being verified and registered into the EndpointV2.
skip only supports to skip nonce right after the last nonce whose message has been verified.
Recall that for a nocne/message to be inserted into EndpointV2, it either needs to be bigger than lazyInboundNonce or its message is not empty(which means not executed before). By setting the lazyInboundNonce to the skipped nonce, in the EndpointV2.verify , the skipped nonce is not bigger than the lazyInboundNonce, and it’s payload is empty, so the EndpointV2 will regard this nonce/message has been executed before thus cant be inserted into EndpointV2 and executed.
// LayerZero/V2/protocol/contracts/EndpointV2.sol /// @dev the caller must provide _nonce to prevent skipping the unintended nonce /// @dev it could happen in some race conditions, e.g. to skip nonce 3, but nonce 3 was consumed first /// @dev usage: skipping the next nonce to prevent message verification, e.g. skip a message when Precrime throws alerts /// @dev if the Oapp wants to skip a verified message, it should call the clear() function instead /// @dev after skipping, the lazyInboundNonce is set to the provided nonce, which makes the inboundNonce also the provided nonce /// @dev ie. allows the Oapp to increment the lazyInboundNonce without having had that corresponding msg be verified function skip(address _oapp, uint32 _srcEid, bytes32 _sender, uint64 _nonce) external { _assertAuthorized(_oapp); if (_nonce != inboundNonce(_oapp, _srcEid, _sender) + 1) revert Errors.LZ_InvalidNonce(_nonce); lazyInboundNonce[_oapp][_srcEid][_sender] = _nonce; emit InboundNonceSkipped(_srcEid, _sender, _oapp, _nonce); } /// @dev returns the max index of the longest gapless sequence of verified msg nonces. /// @dev the uninitialized value is 0. the first nonce is always 1 /// @dev it starts from the lazyInboundNonce (last checkpoint) and iteratively check if the next nonce has been verified /// @dev this function can OOG if too many backlogs, but it can be trivially fixed by just clearing some prior messages /// @dev NOTE: Oapp explicitly skipped nonces count as "verified" for these purposes /// @dev eg. [1,2,3,4,6,7] => 4, [1,2,6,8,10] => 2, [1,3,4,5,6] => 1 function inboundNonce(address _receiver, uint32 _srcEid, bytes32 _sender) public view returns (uint64) { uint64 nonceCursor = lazyInboundNonce[_receiver][_srcEid][_sender]; // find the effective inbound currentNonce unchecked { while (_hasPayloadHash(_receiver, _srcEid, _sender, nonceCursor + 1)) { ++nonceCursor; } } return nonceCursor; } /// @dev checks if the storage slot is not initialized. Assumes computationally infeasible that payload can hash to 0 function _hasPayloadHash( address _receiver, uint32 _srcEid, bytes32 _sender, uint64 _nonce ) internal view returns (bool) { return inboundPayloadHash[_receiver][_srcEid][_sender][_nonce] != EMPTY_PAYLOAD_HASH; }

Clear

OApp can call EndpointV2.clear to prevent a nonce/message to be executed. Its funciton is similar to skip, the difference is that it can specifies which nonce to be cancelled.
// LayerZero/V2/protocol/contracts/EndpointV2.sol /// @dev Oapp uses this interface to clear a message. /// @dev this is a PULL mode versus the PUSH mode of lzReceive /// @dev the cleared message can be ignored by the app (effectively burnt) /// @dev authenticated by oapp /// @param _origin the origin of the message /// @param _guid the guid of the message /// @param _message the message function clear(address _oapp, Origin calldata _origin, bytes32 _guid, bytes calldata _message) external { _assertAuthorized(_oapp); bytes memory payload = abi.encodePacked(_guid, _message); _clearPayload(_oapp, _origin.srcEid, _origin.sender, _origin.nonce, payload); emit PacketDelivered(_origin, _oapp); } /// @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]; }
 

Nilify

OApp can call EndpointV2.nilify to prevent a message from being executed.( This message should be verified already and not executed before).
By setting the payload hash to NIL_PAYLOAD_HASH, relayer is unable to pass correct payload whose hash matches the NIL_PAYLOAD_HASH.
// LayerZero/V2/protocol/contracts/EndpointV2.sol /// @dev Marks a packet as verified, but disallows execution until it is re-verified. /// @dev Reverts if the provided _payloadHash does not match the currently verified payload hash. /// @dev A non-verified nonce can be nilified by passing EMPTY_PAYLOAD_HASH for _payloadHash. /// @dev Assumes the computational intractability of finding a payload that hashes to bytes32.max. /// @dev Authenticated by the caller function nilify(address _oapp, uint32 _srcEid, bytes32 _sender, uint64 _nonce, bytes32 _payloadHash) external { _assertAuthorized(_oapp); bytes32 curPayloadHash = inboundPayloadHash[_oapp][_srcEid][_sender][_nonce]; if (curPayloadHash != _payloadHash) revert Errors.LZ_PayloadHashNotFound(curPayloadHash, _payloadHash); if (_nonce <= lazyInboundNonce[_oapp][_srcEid][_sender] && curPayloadHash == EMPTY_PAYLOAD_HASH) revert Errors.LZ_InvalidNonce(_nonce); // set it to nil inboundPayloadHash[_oapp][_srcEid][_sender][_nonce] = NIL_PAYLOAD_HASH; emit PacketNilified(_srcEid, _sender, _oapp, _nonce, _payloadHash); }

Conclusion

LayerZero V2 introduces several upgrades and enhancements over V1, focusing on increased efficiency, security, and flexibility.
  1. Efficient Gas Usage: V2 optimizes gas consumption through more efficient contract code.
  1. Enhanced Security Stacks: V2 allows developer to choose security stack on product’s needs.
  1. Advanced Messaging Capabilities: V2 supports more complex messaging patterns, including ordered and unordered message delivery, allowing for more flexible and efficient cross-chain communication.
  1. Enhanced Programmability: V2 supports composed message, and each cross-chain message now has GUID(Globally Unique Identifier) which help developer to build complex OApps.