LayerZero V1

LayerZero V1

Tags
Web3
Cross-chain
Published
February 2, 2024
Author
Senn

What’s LayerZero V1?

LayerZero is a omnichain interoperability protocol. To put it simple, it’s a protocol helps send cross-chain messages.
Blockchains are isolated, each has it’s own state,virtual machine and consensus. But there is some need to send cross-chain messages between different blockchains.
A typical example is asset bridge between different blockchains. For example, if Alice wants to send tokens from Ethereum to Polygon, typically there should be token contracts on each blockchain, let’s name the contract on Ethereum T1, and the contract on Polygon T2. When Alice sends token from Ethereum to Polygon, the T1 will lock the token, then T2 will release equivalent amount of token to Alice. So there should be a protocol depend on which T2 can get the information that token has been locked on T1 so that it can release corresponding token. That’s where LayerZero comes in. Layerzero V1 proposes a protocol to facilitate message broadcast between different blockchains.

What can we do using LayerZero V1?

  • Bridge assets between different blockchains
  • Cross-chain dex
  • Cross-Chain Oracles:LayerZero can be used in conjunction with oracle networks to provide reliable cross-chain data feeds, critical for many DeFi applications and smart contract functionalities.
  • Chain-Agnostic DApps: Developers can build decentralized applications (DApps) that are not limited to a single blockchain but can interact with multiple chains for enhanced functionality and user reach.
  • Cross-Chain Governance: For DAOs and other decentralized governance models, LayerZero can facilitate cross-chain governance mechanisms, enabling voting and decision-making processes that span multiple blockchain networks.
  • Multi-Chain Staking and Farming Platforms: LayerZero can enable staking and farming platforms that operate across different blockchains, allowing users to leverage assets from various networks.
  • ……

Logic behind LayerZero

Before diving deep into LayerZero, let’s consider how to implement a cross-chain message protocol by ourselves, becuase blockchains are separated, so we must need some entity to issue transaction to pass the message. In the scenario that Alice wants to bridge her token from Ethereum to Polygon, when the token has been locked on the Ethereum side, there should be an entity which listens the activity on the Ethereum, after having confirmed that the Alice’s token has been locked on the T1, then the entity issue a transaction on Polygon to pass this message and release token on T2.
So if we want to implement a protocol to pass message between blockchains, a basic idea is that we can simply deploy an off-chain node, which listens on-chain activity, and operate on specific blockchains to broadcast messages when certain conditions are fulfilled.
notion image
But the question is how can we make sure that the node won’t do malicious operation? In the above token bridge scenario, the off-chain entity has the ability to release token of T2 contract, what if it mints arbitrary amount of tokens to itself.
LayerZero propose a way to overcome this. The idea is that instead of let single entity to control the cross-chain message, we can let two parties to collaborate to send the message together, and only when two parties are both honest, the message can be sent. So as long as one of the parties are honest, the protocol is safe.
 
LayerZero V1 uses block receipt root and log proof to implement the idea. To put it simple, solidity contract can emit logs contain some data. And those logs of different transactions in one block will participate in a calculation to generate a hash(receipt root) which will be stored in the block header. Thus the hash is an information digest of all the logs in the block.
 
Based on this, we can develop two contracts
  • a contract on Ethereum, when users lock their token, the contract will emit a log which contains the information:” User M has locked N amount of tokens”.
  • a contract on Polygon named cross-chain verifier. One party can publish Ethereum blocks’ receipt hashes on it. And the other party provides log and the proof that the log is included in certain receipt hash published by the other party. If the proof verification passes, then the contract will pass message to another contract based on the data stored in the log.
And here is the token bridge process:
  • Alice calls T1 contract on Ethereum to lock her token
  • T1 contract emit a event contains information: Alice has locked X amount of token. The information is stored in log which is in turn stored in the transaction’s Receipt.
  • Ethereum nodes calculate receipt root based on receipt of each transaction. And publish the receipt root in the block header.
  • There is a party called receipt root publisher which will listen to the event of the T1 contract, once there is new event, then the publisher will publish the corresponding receipt root on the cross-message verifier contract.
 
Because the log contains cross-chain messages, so if both parties are honest, the process will work and we can send cross-chain messages.
notion image
 
Now let’s dive deep into the related concepts in the process.
What’s log?
In EVM, contract can emit event, here is a basic example:
pragma solidity ^0.8.0; contract HelloWorld { // Event declaration event Print(string message); // Function to emit the event with a custom message function sayHello(string memory _message) public { emit Print(_message); } }
In the above code, we define a event name Print,when an account calls the sayHellofunction, the contract will emit a log which records the message passed by the account.
 
What’s receipt root and log proof?
Each block includes some transactions, and each transaction has receipt which includes information about the status of the transaction. For example, success or failure, logs of the transaction, etc.
Here is the related data structure in the go-ethereum codebase, we can see the Block contains a Header, which contains ReceiptHash, which is generate by the receipts. And Receipts contains Logs.
//core/types/block.go type Block struct { header *Header ... } type Header struct { ReceiptHash common.Hash `json:"receiptsRoot" gencodec:"required"` ... }
//core/types/receipt.go // Receipt represents the results of a transaction. type Receipt struct { Logs []*Log `json:"logs" gencodec:"required"` ... }
//core/types/log.go // Log represents a contract log event. These events are generated by the LOG opcode and // stored/indexed by the node. type Log struct { // Consensus fields: // address of the contract that generated the event Address common.Address `json:"address" gencodec:"required"` // list of topics provided by the contract. Topics []common.Hash `json:"topics" gencodec:"required"` // supplied by the contract, usually ABI-encoded Data []byte `json:"data" gencodec:"required"` ... }
In each log, Address records the address of the contract that emitted the event. Topics and Data are data in the event.
 
And how the receipt root calculated?
In Ethereum, receipt root is calculated based on a data structure called StackTrie which is a specialized version of Merkle Patricia Trie(MPT). I won’t dive into this, but we can see the structure in the picture below. Basically, Merkle Patricia Trie is a data structure used to store the key values and faciliate the proof of the data existence and non-existence. In the receipt root case, the key is RLP encoding of index of each receipt in the receipt list. And the value is the RLP encoding of the receipt’s information which includes logs in the receipt.StackTrie is a specialized MPT, which requires the keys should be inserted in a strictly increasing lexicographical order. The advantage of the StackTrie is that it faciliate the saving of memory due to its restriction on the keys insertion.
The root hash is calcualted based on the structure and data of the nodes. Basically hashing the nodes from leafs up to the root to calculate the root hash. So that the root hash contains the information of all nodes and the structure of the trie.
 
notion image
To prove the exsitence of certain node in a StackTrie, we can use the nodes necessary to calculate the root hash as proof. For example, if we want to prove that the red node is on this StackTrie, we can use those green nodes as proof, because we can calculate the root hash based on the green nodes and the red node. We cant give the proof of exsitence of certain node which doesn’t exist in the StackTrie, because hash function is pre-image resistant.
 
notion image
 
Related code the calculate receipt root in the go-ethereum:
//core/types/block.go // NewBlock creates a new block. The input data is copied, // changes to header and to the field values will not affect the // block. // // The values of TxHash, UncleHash, ReceiptHash and Bloom in header // are ignored and set to values derived from the given txs, uncles // and receipts. func NewBlock(header *Header, txs []*Transaction, uncles []*Header, receipts []*Receipt, hasher TrieHasher) *Block { ... if len(receipts) == 0 { b.header.ReceiptHash = EmptyReceiptsHash } else { b.header.ReceiptHash = DeriveSha(Receipts(receipts), hasher) b.header.Bloom = CreateBloom(receipts) } ... return b }
//core/types/hashing.go // DeriveSha creates the tree hashes of transactions, receipts, and withdrawals in a block header. func DeriveSha(list DerivableList, hasher TrieHasher) common.Hash { hasher.Reset() valueBuf := encodeBufferPool.Get().(*bytes.Buffer) defer encodeBufferPool.Put(valueBuf) // StackTrie requires values to be inserted in increasing hash order, which is not the // order that `list` provides hashes in. This insertion sequence ensures that the // order is correct. // // The error returned by hasher is omitted because hasher will produce an incorrect // hash in case any error occurs. var indexBuf []byte for i := 1; i < list.Len() && i <= 0x7f; i++ { indexBuf = rlp.AppendUint64(indexBuf[:0], uint64(i)) value := encodeForDerive(list, i, valueBuf) hasher.Update(indexBuf, value) } if list.Len() > 0 { indexBuf = rlp.AppendUint64(indexBuf[:0], 0) value := encodeForDerive(list, 0, valueBuf) hasher.Update(indexBuf, value) } for i := 0x80; i < list.Len(); i++ { indexBuf = rlp.AppendUint64(indexBuf[:0], uint64(i)) value := encodeForDerive(list, i, valueBuf) hasher.Update(indexBuf, value) } return hasher.Hash() } func encodeForDerive(list DerivableList, i int, buf *bytes.Buffer) []byte { buf.Reset() list.EncodeIndex(i, buf) // It's really unfortunate that we need to do perform this copy. // StackTrie holds onto the values until Hash is called, so the values // written to it must not alias. return common.CopyBytes(buf.Bytes()) }
//core/types/receipt.go // EncodeIndex encodes the i'th receipt to w. func (rs Receipts) EncodeIndex(i int, w *bytes.Buffer) { r := rs[i] data := &receiptRLP{r.statusEncoding(), r.CumulativeGasUsed, r.Bloom, r.Logs} switch r.Type { case LegacyTxType: rlp.Encode(w, data) case AccessListTxType: w.WriteByte(AccessListTxType) rlp.Encode(w, data) case DynamicFeeTxType: w.WriteByte(DynamicFeeTxType) rlp.Encode(w, data) default: // For unsupported types, write nothing. Since this is for // DeriveSha, the error will be caught matching the derived hash // to the block. } }
 

Implementation of LayerZero

notion image

Contract address

network
contract
address
Ethereum
EndPoint
Ethereum
UltraLightNodeV2
Ethereum
VerifierNetwork
Ethereum
PriceFeed#VerifierNetwork
Ethereum
WorkerFeeLib#VerifierNetwork
Ethereum
Treasury
Ethereum
MPTValidator
Ethereum
FPValidator
Polygon
EndPoint
Polygon
UltraLightNodeV2
Polygon
VerifierNetwork
Mumbai
EndPoint(Communicator)
Mumbai
UltraLightNodeV2(Validator)
Optimism-goerli
EndPoint(Communicator)
Optimism-goerli
UltraLightNodeV2(Validator)
Let’s use some example to illustrate the implementation of LayerZero V1. Let’s start from a simple example given by the LayerZero which is called OmniCounter, and then we will build our own OFT and ONFT 721.

Omnicounter

We will deploy two counter in Mumbai and Optimism-goerli. And we will send a transaction to Omnicounter contract on Mumbai to increase the counter of Omnicounter contract on Optimism-goerli. The x-chain message process:
  • deploy two Omnicontract on both chains
  • set trustedRemote in both contracts
  • call Omnicontract.estimateFees to get estimated gas fee
  • call Omnicontract.incrementCounter to send x-chain message
  • the Omnicontract.sol on the other chain will increment the counter stored inside when received the x-chain message
notion image
contract
Omnicounter on mumbai
Omnicounter on optimism-goerli
transaction
send x-chain message on mumbai
receive x-chain message and update counter on optimism-goerli
 
In the omnicounter contract, we can see it inherits NonblockingLzApp.sol which provides a generic message passing interface to send and receive arbitrary pieces of data between contracts existing on different blockchain networks.
//LayerZero/V1/solidity-examples/contracts/examples/OmniCounter.sol // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; pragma abicoder v2; import "../lzApp/NonblockingLzApp.sol"; /// @title A LayerZero example sending a cross chain message from a source chain to a destination chain to increment a counter contract OmniCounter is NonblockingLzApp { bytes public constant PAYLOAD = "\x01\x02\x03\x04"; uint public counter; event CounterPlus(uint num); constructor(address _lzEndpoint) NonblockingLzApp(_lzEndpoint) {} function _nonblockingLzReceive(uint16, bytes memory, uint64, bytes memory) internal override { counter += 1; emit CounterPlus(counter); } function estimateFee(uint16 _dstChainId, bool _useZro, bytes calldata _adapterParams) public view returns (uint nativeFee, uint zroFee) { return lzEndpoint.estimateFees(_dstChainId, address(this), PAYLOAD, _useZro, _adapterParams); } function incrementCounter(uint16 _dstChainId) public payable { _lzSend(_dstChainId, PAYLOAD, payable(msg.sender), address(0x0), bytes(""), msg.value); } function setOracle(uint16 dstChainId, address oracle) external onlyOwner { uint TYPE_ORACLE = 6; // set the Oracle lzEndpoint.setConfig(lzEndpoint.getSendVersion(address(this)), dstChainId, TYPE_ORACLE, abi.encode(oracle)); } function getOracle(uint16 remoteChainId) external view returns (address _oracle) { bytes memory bytesOracle = lzEndpoint.getConfig(lzEndpoint.getSendVersion(address(this)), remoteChainId, address(this), 6); assembly { _oracle := mload(add(bytesOracle, 32)) } } }
Omnicounter.sol implements functions:
  • incrementCounter : send cross-chain message to increment counter in the counter on another chain
  • _nonblockingLzReceive: execution logic when the contract receives cross-chain message from another chain, here the implementation will increment the counter stored in the contract.
  • estimateFee: used to estimate need fee to pay the whole LayerZero protocol
  • setOracle: set the oracle address the Omnicounter wants to use. If not set, then LayerZero will use default oracle.
  • getOracle: get the oracle address used by the Omnicounter.
 

TrustedRemote

Before we call the incrementCounter on Omnicounter.sol, we should first set the trustedRemote on both Omnicounter.sol. TrustedRemote is the trusted path for the cross-chain communication. It has two functionalities:
  • when user want to send cross-chain message, contract will identify the address of the target trusted contract based on the trustedRemote parameter.
  • when a contract receives cross-chain message, it should decide whether the message sender is a trusted remote based on the trustedRemote parameter.
Note that the trustedRemote is packed-encoded (remoteAddress, localAddress), remoteAddress is the target contract address on the destination chain. LocalAddress is the address of this contract. #to:why such encode
//solidity-examples/contracts/lzApp/LzApp.sol // _path = abi.encodePacked(remoteAddress, localAddress) // this function set the trusted path for the cross-chain communication function setTrustedRemote(uint16 _remoteChainId, bytes calldata _path) external onlyOwner { trustedRemoteLookup[_remoteChainId] = _path; emit SetTrustedRemote(_remoteChainId, _path); } function setTrustedRemoteAddress(uint16 _remoteChainId, bytes calldata _remoteAddress) external onlyOwner { trustedRemoteLookup[_remoteChainId] = abi.encodePacked(_remoteAddress, address(this)); emit SetTrustedRemoteAddress(_remoteChainId, _remoteAddress); }
let remoteAndLocal = hre.ethers.utils.solidityPack(["address", "address"], [remoteAddress, localContractInstance.address])

Cross-chain message fee

To use LayerZero send cross-chain message, we need to pay fee. As we illustrate above, there are several roles participating into the x-chain message passing include:
  • Oracle: publish receipt root
  • Relayer: send log and proof to send and execute x-chain message
  • LayerZero: infra structure
So we need to pay fee to these entities.
 
After we have set the trustedRemote on both sides, we can now call the OmniCounter.estimateFee to get the fee needed to send x-chain message.
//solidity-examples/contracts/examples/OmniCounter.sol function estimateFee(uint16 _dstChainId, bool _useZro, bytes calldata _adapterParams) public view returns (uint nativeFee, uint zroFee) { return lzEndpoint.estimateFees(_dstChainId, address(this), PAYLOAD, _useZro, _adapterParams); }
 
The Omnicounter.sol calls the LayerZero Endpoint to estimate fee. The Endpoint first look up the library config of the x-message sender. Then call the library to estimate gas.
 
Note: Each x-message sender can assign their specific config. If they don’t specify, then LayerZero will use default configuration.
//LayerZero/V1/contract/contracts/Endpoint.sol // user app config = [uaAddress] mapping(address => LibraryConfig) public uaConfigLookup; struct LibraryConfig { uint16 sendVersion; uint16 receiveVersion; address receiveLibraryAddress; ILayerZeroMessagingLibrary sendLibrary; } function estimateFees(uint16 _dstChainId, address _userApplication, bytes calldata _payload, bool _payInZRO, bytes calldata _adapterParams) external view override returns (uint nativeFee, uint zroFee) { LibraryConfig storage uaConfig = uaConfigLookup[_userApplication]; ILayerZeroMessagingLibrary lib = uaConfig.sendVersion == DEFAULT_VERSION ? defaultSendLibrary : uaConfig.sendLibrary; return lib.estimateFees(_dstChainId, _userApplication, _payload, _payInZRO, _adapterParams); }
 
The LayerZeroMessagingLibrary is in fact UltraLightNode(Validator).
First fetch the ApplicationConfiguration of the x-message sender based on the destination chain id and the user address.Sender can specify his/her configuration for each destination chain. If sender doesn’t set configuration, then LayerZero will use default configuration.
type
field
functionality
uint16
inboundProofLibraryVersion
version of inbound proof library
uint64
inboundBlockConfirmations
block confirmations needed for inbound message
address
relayer
relayer contract which estimates the relayer fee
uint16
outboundProofType
This parameter specifies the type of proof required for outbound transactions which is related to the fee of relayer.
uint64
outboundBlockConfirmations
address
oracle
oracle contract which estimates the oracle fee
bytes
adapterParams
Encoded information passed to the relayer
//LayerZero/V1/contract/contracts/UltraLightNodeV2.sol // returns the native fee the UA pays to cover fees function estimateFees(uint16 _dstChainId, address _ua, bytes calldata _payload, bool _payInZRO, bytes calldata _adapterParams) external view override returns (uint nativeFee, uint zroFee) { ApplicationConfiguration memory uaConfig = _getAppConfig(_dstChainId, _ua); // Relayer Fee bytes memory adapterParams; if (_adapterParams.length > 0) { adapterParams = _adapterParams; } else { adapterParams = defaultAdapterParams[_dstChainId][uaConfig.outboundProofType]; } uint relayerFee = ILayerZeroRelayerV2(uaConfig.relayer).getFee(_dstChainId, uaConfig.outboundProofType, _ua, _payload.length, adapterParams); // Oracle Fee address ua = _ua; // stack too deep uint oracleFee = ILayerZeroOracleV2(uaConfig.oracle).getFee(_dstChainId, uaConfig.outboundProofType, uaConfig.outboundBlockConfirmations, ua); // LayerZero Fee uint protocolFee = treasuryContract.getFees(_payInZRO, relayerFee, oracleFee); _payInZRO ? zroFee = protocolFee : nativeFee = protocolFee; // return the sum of fees nativeFee = nativeFee.add(relayerFee).add(oracleFee); } struct ApplicationConfiguration { uint16 inboundProofLibraryVersion; uint64 inboundBlockConfirmations; address relayer; uint16 outboundProofType; uint64 outboundBlockConfirmations; address oracle; } // default to DEFAULT setting if ZERO value function getAppConfig(uint16 _remoteChainId, address _ua) external view override returns (ApplicationConfiguration memory) { return _getAppConfig(_remoteChainId, _ua); } function _getAppConfig(uint16 _remoteChainId, address _ua) internal view returns (ApplicationConfiguration memory) { ApplicationConfiguration memory config = appConfig[_ua][_remoteChainId]; ApplicationConfiguration storage defaultConfig = defaultAppConfig[_remoteChainId]; if (config.inboundProofLibraryVersion == 0) { config.inboundProofLibraryVersion = defaultConfig.inboundProofLibraryVersion; } if (config.inboundBlockConfirmations == 0) { config.inboundBlockConfirmations = defaultConfig.inboundBlockConfirmations; } if (config.relayer == address(0x0)) { config.relayer = defaultConfig.relayer; } if (config.outboundProofType == 0) { config.outboundProofType = defaultConfig.outboundProofType; } if (config.outboundBlockConfirmations == 0) { config.outboundBlockConfirmations = defaultConfig.outboundBlockConfirmations; } if (config.oracle == address(0x0)) { config.oracle = defaultConfig.oracle; } return config; }
 

Send x-chain message(local)

We can call OmniCounter.incrementCounter to send x-chain message to increment the counter of the Omnicounter.sol on the other chain. The basic process is that sender call Endpoint to handle fees and emit packet which will be listened by Relayer and Oracle.
 
Inside the incrementCounter, it calls lzApp._lzSend which checks the payload size limitatin and then calls the Endpoint.send.
//LayerZero/V1/solidity-examples/contracts/examples/OmniCounter.sol // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; pragma abicoder v2; import "../lzApp/NonblockingLzApp.sol"; /// @title A LayerZero example sending a cross chain message from a source chain to a destination chain to increment a counter contract OmniCounter is NonblockingLzApp { ... function incrementCounter(uint16 _dstChainId) public payable { _lzSend(_dstChainId, PAYLOAD, payable(msg.sender), address(0x0), bytes(""), msg.value); } ... } //OmniCounter#lzApp function _lzSend( uint16 _dstChainId, bytes memory _payload, address payable _refundAddress, address _zroPaymentAddress, bytes memory _adapterParams, uint _nativeFee ) internal virtual { bytes memory trustedRemote = trustedRemoteLookup[_dstChainId]; require(trustedRemote.length != 0, "LzApp: destination chain is not a trusted source"); _checkPayloadSize(_dstChainId, _payload.length); lzEndpoint.send{value: _nativeFee}(_dstChainId, trustedRemote, _payload, _refundAddress, _zroPaymentAddress, _adapterParams); } // ua can not send payload larger than this by default, but it can be changed by the ua owner uint public constant DEFAULT_PAYLOAD_SIZE_LIMIT = 10000; function _checkPayloadSize(uint16 _dstChainId, uint _payloadSize) internal view virtual { uint payloadSizeLimit = payloadSizeLimitLookup[_dstChainId]; if (payloadSizeLimit == 0) { // use default if not set payloadSizeLimit = DEFAULT_PAYLOAD_SIZE_LIMIT; } require(_payloadSize <= payloadSizeLimit, "LzApp: payload size is too large"); }
 

Fetch Uln and update nonce

In the Endpoint, it first fetches the send library(UltraLightNode) of user, if the user hasn’t specified any send library, then default send library will be used.
Note:
  • there is a storage called outboundNonce,which records the x-chain message amount the sender sends to the destination chain.
  • the _destinationis trustedRemote set before which records local address and destination contract address. Because the destination chain may not be EVM compatible, so the destination contract address may not be 160 bits, LayerZero uses bytes to contain the path information.
//LayerZero/V1/contract/contracts/Endpoint.sol struct LibraryConfig { uint16 sendVersion; uint16 receiveVersion; address receiveLibraryAddress; ILayerZeroMessagingLibrary sendLibrary; } // outboundNonce = [dstChainId][srcAddress]. mapping(uint16 => mapping(address => uint64)) public outboundNonce; // _dstChainId : destination Endpoint id // _destination : x-chain message path. This is essentially the trusted remote. // _payload : calldata to send on the destination chain // _refundAddress : used to accept the refund fee if the value provided by the sender is bigger than the required fees. // _zroPaymentAddress : #to // _adapterParams : relayer configuration, including tx type, extraGas, native token amount. function send(uint16 _dstChainId, bytes calldata _destination, bytes calldata _payload, address payable _refundAddress, address _zroPaymentAddress, bytes calldata _adapterParams) external payable override sendNonReentrant { LibraryConfig storage uaConfig = uaConfigLookup[msg.sender]; uint64 nonce = ++outboundNonce[_dstChainId][msg.sender]; _getSendLibrary(uaConfig).send{value: msg.value}(msg.sender, nonce, _dstChainId, _destination, _payload, _refundAddress, _zroPaymentAddress, _adapterParams); } // installed libraries and reserved versions uint16 public constant BLOCK_VERSION = 65535; uint16 public constant DEFAULT_VERSION = 0; // default send/receive libraries uint16 public defaultSendVersion; uint16 public defaultReceiveVersion; ILayerZeroMessagingLibrary public defaultSendLibrary; function _getSendLibrary(LibraryConfig storage uaConfig) internal view returns (ILayerZeroMessagingLibrary) { if (uaConfig.sendVersion == DEFAULT_VERSION) { // check if the in send-blocking upgrade require(defaultSendVersion != BLOCK_VERSION, "LayerZero: default in BLOCK_VERSION"); return defaultSendLibrary; } else { // check if the in send-blocking upgrade require(uaConfig.sendVersion != BLOCK_VERSION, "LayerZero: in BLOCK_VERSION"); return uaConfig.sendLibrary; } }
 
In the UltraLightNode, it will first do some checks:
  • the destination(dstChainId) chain is supported by LayerZero
  • the _pathis correctly encoded in the aspect of the size of local chain and destination chain contract addresses. LayerZero decodes the source and destination contract addresses from the _path,and require the source address matches the sender contract address.
 
Please be aware that the UltraLightNode (Uln) utilizes nonceContract.sol to increment the nonce. This nonce serves as a unique identifier for x-chain messages originating from a specific source and targeting a specific destination contract on the destination chain. Due to its incremental nature, the nonce is instrumental in maintaining the sequence of x-chain messages. It ensures that messages sent from this contract to the destination contract are processed in the correct order and not shuffled.
 

Uln emit packet

Then Uln will handle fees paied to relayer, oracle and protocol, also checks that the value passed by the sender can cover the fees, and refund the remaining fee back to the _refundAddress.After all the fees handled, then Uln will emit a message packet to notify oracle and executor the perform the protocol to send the x-chain message.
//LayerZero/V1/contract/contracts/UltraLightNodeV2.sol // _path: // bytes [x 20 ] // fields [remote address local address(EVM-chain)] function send(address _ua, uint64, uint16 _dstChainId, bytes calldata _path, bytes calldata _payload, address payable _refundAddress, address _zroPaymentAddress, bytes calldata _adapterParams) external payable override onlyEndpoint { address ua = _ua; uint16 dstChainId = _dstChainId; require(ulnLookup[dstChainId] != bytes32(0), "LayerZero: dstChainId does not exist"); bytes memory dstAddress; uint64 nonce; // code block for solving 'Stack Too Deep' { uint chainAddressSize = chainAddressSizeMap[dstChainId]; // path = remoteAddress + localAddress require(chainAddressSize != 0 && _path.length == 20 + chainAddressSize, "LayerZero: incorrect remote address size"); address srcInPath; bytes memory path = _path; // copy to memory assembly { srcInPath := mload(add(add(path, 20), chainAddressSize)) // chainAddressSize + 20 } require(ua == srcInPath, "LayerZero: wrong path data"); dstAddress = _path[0:chainAddressSize]; nonce = nonceContract.increment(dstChainId, ua, path); } bytes memory payload = _payload; ApplicationConfiguration memory uaConfig = _getAppConfig(dstChainId, ua); // compute all the fees uint relayerFee = _handleRelayer(dstChainId, uaConfig, ua, payload.length, _adapterParams); uint oracleFee = _handleOracle(dstChainId, uaConfig, ua); uint nativeProtocolFee = _handleProtocolFee(relayerFee, oracleFee, ua, _zroPaymentAddress); // total native fee, does not include ZRO protocol fee uint totalNativeFee = relayerFee.add(oracleFee).add(nativeProtocolFee); // assert the user has attached enough native token for this address require(totalNativeFee <= msg.value, "LayerZero: not enough native for fees"); // refund if they send too much uint amount = msg.value.sub(totalNativeFee); if (amount > 0) { (bool success, ) = _refundAddress.call{value: amount}(""); require(success, "LayerZero: failed to refund"); } // emit the data packet bytes memory encodedPayload = abi.encodePacked(nonce, localChainId, ua, dstChainId, dstAddress, payload); emit Packet(encodedPayload); }
//LayerZero/V1/contract/contracts/NonceContract.sol // SPDX-License-Identifier: BUSL-1.1 contract NonceContract { ILayerZeroEndpoint public immutable endpoint; // outboundNonce = [dstChainId][remoteAddress + localAddress] mapping(uint16 => mapping(bytes => uint64)) public outboundNonce; function increment(uint16 _chainId, address _ua, bytes calldata _path) external returns (uint64) { require(endpoint.getSendLibraryAddress(_ua) == msg.sender, "NonceContract: msg.sender is not valid sendlibrary"); return ++outboundNonce[_chainId][_path]; } }
 
Let’s first look at how LayerZero handles the fees.
The first part is relayer fee.
Uln calls the Relayer.sol to assign a job and get the relayer fee. Then Uln cumulate the fee on the relayer using storage nativeFees.
//LayerZero/V1/contract/contracts/UltraLightNodeV2.sol function _handleRelayer(uint16 _dstChainId, ApplicationConfiguration memory _uaConfig, address _ua, uint _payloadSize, bytes memory _adapterParams) internal returns (uint relayerFee) { if (_adapterParams.length == 0) { _adapterParams = defaultAdapterParams[_dstChainId][_uaConfig.outboundProofType]; } address relayerAddress = _uaConfig.relayer; ILayerZeroRelayerV2 relayer = ILayerZeroRelayerV2(relayerAddress); relayerFee = relayer.assignJob(_dstChainId, _uaConfig.outboundProofType, _ua, _payloadSize, _adapterParams); _creditNativeFee(relayerAddress, relayerFee); // emit the param events emit RelayerParams(_adapterParams, _uaConfig.outboundProofType); } mapping(address => uint) public nativeFees; function _creditNativeFee(address _receiver, uint _amount) internal { nativeFees[_receiver] = nativeFees[_receiver].add(_amount); }
//LayerZero/V1/contract/contracts/RelayerV2.sol function assignJob(uint16 _dstChainId, uint16 _outboundProofType, address _userApplication, uint _payloadSize, bytes calldata _adapterParams) external override returns (uint) { require(msg.sender == address(uln), "Relayer: invalid uln"); require(_payloadSize <= 10000, "Relayer: _payloadSize tooooo big"); (uint basePrice, uint pricePerByte) = _getPrices(_dstChainId, _outboundProofType, _userApplication, _adapterParams); uint totalFee = basePrice.add(_payloadSize.mul(pricePerByte)); emit AssignJob(totalFee); return totalFee; }
 
The second part fee is oracle fee.
//LayerZero/V1/contract/contracts/UltraLightNodeV2.sol function _handleOracle(uint16 _dstChainId, ApplicationConfiguration memory _uaConfig, address _ua) internal returns (uint oracleFee) { address oracleAddress = _uaConfig.oracle; oracleFee = ILayerZeroOracleV2(oracleAddress).assignJob(_dstChainId, _uaConfig.outboundProofType, _uaConfig.outboundBlockConfirmations, _ua); _creditNativeFee(oracleAddress, oracleFee); }
//VerifierNetwork.sol /// @dev for ULN301, ULN302 and more to assign job /// @dev verifier network can reject job from _sender by adding/removing them from allowlist/denylist /// @param _param assign job param /// @param _options verifier options function assignJob( AssignJobParam calldata _param, bytes calldata _options ) external payable onlyRole(MESSAGE_LIB_ROLE) onlyAcl(_param.sender) returns (uint totalFee) { IVerifierFeeLib.FeeParams memory feeParams = IVerifierFeeLib.FeeParams( priceFeed, _param.dstEid, _param.confirmations, _param.sender, quorum, defaultMultiplierBps ); totalFee = IVerifierFeeLib(workerFeeLib).getFeeOnSend(feeParams, dstConfig[_param.dstEid], _options); }
 
The third part is protocol fee
Uln first decideds whether use ZRO or native token as protocol fee. Then calls treasuryContract.sol to fetch the fee.
If using native token as fee, then Unl just credits those fee to the treasuryContract. Else, calls the layerZeroToken.sol to transfer ZRO to Uln and cumulate the ZRO fees recorded by treasuryZROFees.
//LayerZero/V1/contract/contracts/UltraLightNodeV2.sol function _handleProtocolFee(uint _relayerFee, uint _oracleFee, address _ua, address _zroPaymentAddress) internal returns (uint protocolNativeFee) { // if no ZRO token or not specifying a payment address, pay in native token bool payInNative = _zroPaymentAddress == address(0x0) || address(layerZeroToken) == address(0x0); uint protocolFee = treasuryContract.getFees(!payInNative, _relayerFee, _oracleFee); if (protocolFee > 0) { if (payInNative) { address treasuryAddress = address(treasuryContract); _creditNativeFee(treasuryAddress, protocolFee); protocolNativeFee = protocolFee; } else { // zro payment address must equal the ua or the tx.origin otherwise the transaction reverts require(_zroPaymentAddress == _ua || _zroPaymentAddress == tx.origin, "LayerZero: must be paid by sender or origin"); // transfer the LayerZero token to this contract from the payee layerZeroToken.safeTransferFrom(_zroPaymentAddress, address(this), protocolFee); treasuryZROFees = treasuryZROFees.add(protocolFee); } } }
uint public zroFee; bool public feeEnabled; bool public zroEnabled; //LayerZero/V1/contract/contracts/TreasuryV2.sol function getFees(bool payInZro, uint relayerFee, uint oracleFee) external view override returns (uint) { if (feeEnabled) { if (payInZro) { require(zroEnabled, "LayerZero: ZRO is not enabled"); return zroFee; } else { return relayerFee.add(oracleFee).mul(nativeBP).div(10000); } } return 0; }
 

Packet

Information included in the emitted packet:
field
type
functionality
nonce
uint64
x-chain message identifier in the channle from sender to the destination contract
localChainId
uint16
local chain Endpoint id
ua
address
x-chain message sender’s address
dstChainId
uint16
destination chain Endpoint Id
desAddress
bytes
contract address on the destination chain ( using bytes, because the destination chain may not be EVM)
payload
bytes
calldata of the x-chain message
//LayerZero/V1/contract/contracts/UltraLightNodeV2.sol function send(address _ua, uint64, uint16 _dstChainId, bytes calldata _path, bytes calldata _payload, address payable _refundAddress, address _zroPaymentAddress, bytes calldata _adapterParams) external payable override onlyEndpoint { ... // emit the data packet bytes memory encodedPayload = abi.encodePacked(nonce, localChainId, ua, dstChainId, dstAddress, payload); emit Packet(encodedPayload); }
An interesting thing is that, the information of native token value passed in the transaction on the target chain doesn’t included in the packet. #to: how does relayer fetch this information?
 

Send x-chain message(target)

After the packet has been successfully emitted on the local chain and be fetched by the Oracle and Relayer.
The oracle will first publish receipt hash on the target chain, and then Relayer will pass the calldata and proof to Uln on the target chain to send x-chain message.
 

Oracle update block hash and receipt root.

// Can be called by any address to update a block header // can only upload new block data or the same block data with more confirmations function updateHash(uint16 _srcChainId, bytes32 _lookupHash, uint _confirmations, bytes32 _blockData) external override { uint storedConfirmations = hashLookup[msg.sender][_srcChainId][_lookupHash][_blockData]; // if it has a record, requires a larger confirmation. require(storedConfirmations < _confirmations, "LayerZero: oracle data can only update if it has more confirmations"); // set the new information into storage hashLookup[msg.sender][_srcChainId][_lookupHash][_blockData] = _confirmations; emit HashReceived(_srcChainId, msg.sender, _lookupHash, _blockData, _confirmations); }
 

Relayer sends x-chain message

Relayer calls the RelayerV2.validateTransactionProof to pass the payload and proof.
Decode the calldata passed by Relayer, we can get that:
_srcChainId
10109
_dstAddress
0x281bB21c5302D70383A6B3a4692f7B64D42C9218
_gasLimit
200000
_lookupHash
0x875a771bee28a65ed053e7f9d9eb18ee99230c690e0663d24ff5c3f96615ec1d
_blockData
0x6f7c07890aef08d83e99dcc7e4fd6857749a102f6fb716d605e136d7d3c8290b
_lookupHash is the block hash of the block 43983333 of mumbai network where the x-chain message was sent.
_blockDatais the receipt root of the block 43983333 of mumbai network.
 
Inside the validateTransactionProof
  • first fetch the configuration of the destination contract on the source chain.
  • check whether the confirmation of block satisfies the requirement of the configuration.
  • call the inboundProofLib to validate proof and decode out the packet. Note that tha _transactionProof is encoded from source chain’s UltralightNode address and packet. So _transactionProof = srcUlnAddress (32 bytes) + lzPacket
  • checks the source UltralightNode address matches between configuration and data passed by relayer
  • check the source chain id matches between packet and data passed by relayer
  • check the address length of the source chain x-chain message sender
  • check the dstChainId in the packet matches the current blockchain’s id
  • check the destination address matches between packet and data passed by relayer
  • check whether the destination address is a contract
  • calls Endpoint.receivePayload to send x-chain message
 
There are two versions of validateTransactionProof. Compared to V1, V2 supports to transfer value to the destination first.
//LayerZero/V1/contract/contracts/RelayerV2.sol function validateTransactionProofV2(uint16 _srcChainId, address _dstAddress, uint _gasLimit, bytes32 _blockHash, bytes32 _data, bytes calldata _transactionProof, address payable _to) external payable onlyApproved nonReentrant { (bool sent, ) = _to.call{value: msg.value}(""); //require(sent, "Relayer: failed to send ether"); if (!sent) { emit ValueTransferFailed(_to, msg.value); } uln.validateTransactionProof(_srcChainId, _dstAddress, _gasLimit, _blockHash, _data, _transactionProof); } function validateTransactionProofV1(uint16 _srcChainId, address _dstAddress, uint _gasLimit, bytes32 _blockHash, bytes32 _data, bytes calldata _transactionProof) external onlyApproved nonReentrant { uln.validateTransactionProof(_srcChainId, _dstAddress, _gasLimit, _blockHash, _data, _transactionProof); }
//LayerZero/V1/contract/contracts/UltraLightNodeV2.sol struct ApplicationConfiguration { uint16 inboundProofLibraryVersion; uint64 inboundBlockConfirmations; address relayer; uint16 outboundProofType; uint64 outboundBlockConfirmations; address oracle; } function validateTransactionProof(uint16 _srcChainId, address _dstAddress, uint _gasLimit, bytes32 _lookupHash, bytes32 _blockData, bytes calldata _transactionProof) external override { // retrieve UA's configuration using the _dstAddress from arguments. ApplicationConfiguration memory uaConfig = _getAppConfig(_srcChainId, _dstAddress); // assert that the caller == UA's relayer require(uaConfig.relayer == msg.sender, "LayerZero: invalid relayer"); LayerZeroPacket.Packet memory _packet; uint remoteAddressSize = chainAddressSizeMap[_srcChainId]; require(remoteAddressSize != 0, "LayerZero: incorrect remote address size"); { // assert that the data submitted by UA's oracle have no fewer confirmations than UA's configuration uint storedConfirmations = hashLookup[uaConfig.oracle][_srcChainId][_lookupHash][_blockData]; require(storedConfirmations > 0 && storedConfirmations >= uaConfig.inboundBlockConfirmations, "LayerZero: not enough block confirmations"); // decode address inboundProofLib = inboundProofLibrary[_srcChainId][uaConfig.inboundProofLibraryVersion]; _packet = ILayerZeroValidationLibrary(inboundProofLib).validateProof(_blockData, _transactionProof, remoteAddressSize); } // packet content assertion require(ulnLookup[_srcChainId] == _packet.ulnAddress && _packet.ulnAddress != bytes32(0), "LayerZero: invalid _packet.ulnAddress"); require(_packet.srcChainId == _srcChainId, "LayerZero: invalid srcChain Id"); // failsafe because the remoteAddress size being passed into validateProof trims the address this should not hit require(_packet.srcAddress.length == remoteAddressSize, "LayerZero: invalid srcAddress size"); require(_packet.dstChainId == localChainId, "LayerZero: invalid dstChain Id"); require(_packet.dstAddress == _dstAddress, "LayerZero: invalid dstAddress"); // if the dst is not a contract, then emit and return early. This will break inbound nonces, but this particular // path is already broken and wont ever be able to deliver anyways if (!_isContract(_dstAddress)) { emit InvalidDst(_packet.srcChainId, _packet.srcAddress, _packet.dstAddress, _packet.nonce, keccak256(_packet.payload)); return; } bytes memory pathData = abi.encodePacked(_packet.srcAddress, _packet.dstAddress); emit PacketReceived(_packet.srcChainId, _packet.srcAddress, _packet.dstAddress, _packet.nonce, keccak256(_packet.payload)); endpoint.receivePayload(_srcChainId, pathData, _dstAddress, _packet.nonce, _gasLimit, _packet.payload); }
 
In the uln, it calls inboundProofLib.validateProof to validate proof. Currently inboundProofLib has two versions, one uses receipt root to verify(MPTValidator01.sol), and the other uses hash of packet to verify(FPValidator.sol).
//LayerZero/V1/contract/contracts/proof/MPTValidator01.sol function validateProof(bytes32 _receiptsRoot, bytes calldata _transactionProof, uint _remoteAddressSize) external view override returns (LayerZeroPacket.Packet memory packet) { require(_remoteAddressSize > 0, "ProofLib: invalid address size"); (bytes[] memory proof, uint[] memory receiptSlotIndex, uint logIndex) = abi.decode(_transactionProof, (bytes[], uint[], uint)); ULNLog memory log = _getVerifiedLog(_receiptsRoot, receiptSlotIndex, logIndex, proof); require(log.topicZeroSig == PACKET_SIGNATURE, "ProofLib: packet not recognized"); //data packet = LayerZeroPacket.getPacketV2(log.data, _remoteAddressSize, log.contractAddress); if (packet.dstAddress == stargateBridgeAddress) packet.payload = _secureStgPayload(packet.payload); if (packet.dstAddress == stargateTokenAddress) packet.payload = _secureStgTokenPayload(packet.payload); return packet; }
//LayerZero/V1/contract/contracts/proof/FPValidator.sol function validateProof(bytes32 _packetHash, bytes calldata _transactionProof, uint _remoteAddressSize) external view override returns (LayerZeroPacket.Packet memory packet) { require(_remoteAddressSize > 0, "ProofLib: invalid address size"); // _transactionProof = srcUlnAddress (32 bytes) + lzPacket require(_transactionProof.length > 32 && keccak256(_transactionProof) == _packetHash, "ProofLib: invalid transaction proof"); bytes memory ulnAddressBytes = bytes(_transactionProof[0:32]); bytes32 ulnAddress; assembly { ulnAddress := mload(add(ulnAddressBytes, 32)) } packet = LayerZeroPacket.getPacketV3(_transactionProof[32:], _remoteAddressSize, ulnAddress); if (packet.dstAddress == stargateBridgeAddress) packet.payload = _secureStgPayload(packet.payload); if (packet.dstAddress == stargateTokenAddress) packet.payload = _secureStgTokenPayload(packet.payload); return packet; }
 
In the Endpoint.receivePayload
  • it first checks whether the nonce matches the inboundNonce based on the path([sourceAddr,destinationAddr]). This assures that the x-chain message between source and destionation are restrictly ordered.
  • then get the receiveLibraryAddress of the destionation contract and check the msg.sender is the receiveLibrary
  • make sure there are no blocked transactions from source to destination
  • call the _dstAddress.lzReceive to send the x-chain message finally
  • if the transaction failed for some reason, then store the related information this x-chain message on the storage storedPayload for latter retry. Also emit event to notify off-chain entity.
 
//LayerZero/V1/contract/contracts/Endpoint.sol //--------------------------------------------------------------------------- // authenticated Library (msg.sender) Calls to pass through Endpoint to UA (dstAddress) function receivePayload(uint16 _srcChainId, bytes calldata _srcAddress, address _dstAddress, uint64 _nonce, uint _gasLimit, bytes calldata _payload) external override receiveNonReentrant { // assert and increment the nonce. no message shuffling require(_nonce == ++inboundNonce[_srcChainId][_srcAddress], "LayerZero: wrong nonce"); LibraryConfig storage uaConfig = uaConfigLookup[_dstAddress]; // authentication to prevent cross-version message validation // protects against a malicious library from passing arbitrary data if (uaConfig.receiveVersion == DEFAULT_VERSION) { require(defaultReceiveLibraryAddress == msg.sender, "LayerZero: invalid default library"); } else { require(uaConfig.receiveLibraryAddress == msg.sender, "LayerZero: invalid library"); } // block if any message blocking StoredPayload storage sp = storedPayload[_srcChainId][_srcAddress]; require(sp.payloadHash == bytes32(0), "LayerZero: in message blocking"); try ILayerZeroReceiver(_dstAddress).lzReceive{gas: _gasLimit}(_srcChainId, _srcAddress, _nonce, _payload) { // success, do nothing, end of the message delivery } catch (bytes memory reason) { // revert nonce if any uncaught errors/exceptions if the ua chooses the blocking mode storedPayload[_srcChainId][_srcAddress] = StoredPayload(uint64(_payload.length), _dstAddress, keccak256(_payload)); emit PayloadStored(_srcChainId, _srcAddress, _dstAddress, _nonce, _payload, reason); } }
 
If some transaction does failed for some reason, anyone can call the Endpoint.retryPayload to re-send the x-chain message.
//LayerZero/V1/contract/contracts/Endpoint.sol function retryPayload(uint16 _srcChainId, bytes calldata _srcAddress, bytes calldata _payload) external override receiveNonReentrant { StoredPayload storage sp = storedPayload[_srcChainId][_srcAddress]; require(sp.payloadHash != bytes32(0), "LayerZero: no stored payload"); require(_payload.length == sp.payloadLength && keccak256(_payload) == sp.payloadHash, "LayerZero: invalid payload"); address dstAddress = sp.dstAddress; // empty the storedPayload sp.payloadLength = 0; sp.dstAddress = address(0); sp.payloadHash = bytes32(0); uint64 nonce = inboundNonce[_srcChainId][_srcAddress]; ILayerZeroReceiver(dstAddress).lzReceive(_srcChainId, _srcAddress, nonce, _payload); emit PayloadCleared(_srcChainId, _srcAddress, nonce, dstAddress); }
Remember that the Omnicounter inherits the NonblockingLzApp.sol? Yes, the LayerZero team has already implemented the frame for us. There is implementation of lzReceive in the lzReceive of LzApp.sol(inherited by NonblockingLzApp.sol), which checks the msg.sender is the configured Endpoint and the path(_srcAddress)is trusted. Then it calls _blockingLzReceivewhich calls the nonblockingLzReceive implemented by developer, if this steps fails, then it will store the failure information and emit event. Finally, in the Omnicounter, nonblockingLzReceiveincrements the counter, and the x-chain message has been successfully sent!
//LayerZero/V1/solidity-examples/contracts/lzApp/LzApp.sol function lzReceive( uint16 _srcChainId, bytes calldata _srcAddress, uint64 _nonce, bytes calldata _payload ) public virtual override { // lzReceive must be called by the endpoint for security require(_msgSender() == address(lzEndpoint), "LzApp: invalid endpoint caller"); bytes memory trustedRemote = trustedRemoteLookup[_srcChainId]; // if will still block the message pathway from (srcChainId, srcAddress). should not receive message from untrusted remote. require( _srcAddress.length == trustedRemote.length && trustedRemote.length > 0 && keccak256(_srcAddress) == keccak256(trustedRemote), "LzApp: invalid source sending contract" ); _blockingLzReceive(_srcChainId, _srcAddress, _nonce, _payload); }
 
//LayerZero/V1/solidity-examples/contracts/lzApp/NonblockingLzApp.sol // overriding the virtual function in LzReceiver function _blockingLzReceive( uint16 _srcChainId, bytes memory _srcAddress, uint64 _nonce, bytes memory _payload ) internal virtual override { (bool success, bytes memory reason) = address(this).excessivelySafeCall( gasleft(), 150, abi.encodeWithSelector(this.nonblockingLzReceive.selector, _srcChainId, _srcAddress, _nonce, _payload) ); if (!success) { _storeFailedMessage(_srcChainId, _srcAddress, _nonce, _payload, reason); } } function _storeFailedMessage( uint16 _srcChainId, bytes memory _srcAddress, uint64 _nonce, bytes memory _payload, bytes memory _reason ) internal virtual { failedMessages[_srcChainId][_srcAddress][_nonce] = keccak256(_payload); emit MessageFailed(_srcChainId, _srcAddress, _nonce, _payload, _reason); }
//LayerZero/V1/solidity-examples/contracts/examples/OmniCounter.sol function _nonblockingLzReceive(uint16, bytes memory, uint64, bytes memory) internal override { counter += 1; emit CounterPlus(counter); }

Appendix

Relayer Fee

Some fee is paid to relayer to send the x-chain message. UltraLightNode calls the Relayer contract to calculate the fee. Note that the Relayer contract is specified by the sender.
In the Omnicounter example, the adapterParams is packed-encoed txType and extraGas.
  • txType is the transaction type the user willing to send. Normally, the txType 1 represents a call to some contract without value. txType 2 can specify the value passed to the target contract.
  • extraGas is the gas sender willing to pay besides the basical gas used to send transaction.
// returns the native fee the UA pays to cover fees function estimateFees(uint16 _dstChainId, address _ua, bytes calldata _payload, bool _payInZRO, bytes calldata _adapterParams) external view override returns (uint nativeFee, uint zroFee) { ... uint relayerFee = ILayerZeroRelayerV2(uaConfig.relayer).getFee(_dstChainId, uaConfig.outboundProofType, _ua, _payload.length, adapterParams); ... }
const adapterParams = ethers.utils.solidityPack(["uint16", "uint256"], [1, 200000])
 
In the getFeeimplementation, we can find that the fee is basically devided into 4 parts
  • baseGas : the basical fee to send x-chain message on the destination chain(include proof)
  • extraGas : extra gas the sender willing to pay the transaction on the target chain
  • gasPerByte : gas per byte, this is fit for Layer2, because Layer2 transaction fee includes L1 calldata fee, which is related to calldata bytes amount
  • dstNativeAmt: value the sender wants to pass to the contract on the destination chain
With the dstGasPriceInWei, we can calculate the fee based on native token of the destination chain. Using the dstPriceRatio,relayer can calculate the fee based on local blockchain.
So the fee calculation formula is:
//LayerZero/V1/contract/contracts/RelayerV2.sol function getFee(uint16 _dstChainId, uint16 _outboundProofType, address _userApplication, uint _payloadSize, bytes calldata _adapterParams) external view override returns (uint) { require(_payloadSize <= 10000, "Relayer: _payloadSize tooooo big"); (uint basePrice, uint pricePerByte) = _getPrices(_dstChainId, _outboundProofType, _userApplication, _adapterParams); return basePrice.add(_payloadSize.mul(pricePerByte)); } // txType 1 // bytes [2 32 ] // fields [txType extraGas] // txType 2 // bytes [2 32 32 bytes[] ] // fields [txType extraGas dstNativeAmt dstNativeAddress] // User App Address is not used in this version function _getPrices(uint16 _dstChainId, uint16 _outboundProofType, address, bytes memory _adapterParameters) internal view returns (uint basePrice, uint pricePerByte) { require(!paused, "Admin: paused"); // decoding the _adapterParameters - reverts if type 2 and there is no dstNativeAddress require(_adapterParameters.length == 34 || _adapterParameters.length > 66, "Relayer: wrong _adapterParameters size"); uint16 txType; uint extraGas; assembly { txType := mload(add(_adapterParameters, 2)) extraGas := mload(add(_adapterParameters, 34)) } require(extraGas > 0, "Relayer: gas too low"); require(txType == 1 || txType == 2, "Relayer: unsupported txType"); DstPrice storage dstPrice = dstPriceLookup[_dstChainId]; DstConfig storage dstConfig = dstConfigLookup[_dstChainId][_outboundProofType]; uint totalRemoteToken; // = baseGas + extraGas + requiredNativeAmount if (txType == 2) { uint dstNativeAmt; assembly { dstNativeAmt := mload(add(_adapterParameters, 66)) } require(dstConfig.dstNativeAmtCap >= dstNativeAmt, "Relayer: dstNativeAmt too large"); totalRemoteToken = totalRemoteToken.add(dstNativeAmt); } // remoteGasTotal = dstGasPriceInWei * (baseGas + extraGas) uint remoteGasTotal = dstPrice.dstGasPriceInWei.mul(dstConfig.baseGas.add(extraGas)); totalRemoteToken = totalRemoteToken.add(remoteGasTotal); // tokenConversionRate = dstPrice / localPrice // basePrice = totalRemoteToken * tokenConversionRate basePrice = totalRemoteToken.mul(dstPrice.dstPriceRatio).div(10**10); // pricePerByte = (dstGasPriceInWei * gasPerBytes) * tokenConversionRate pricePerByte = dstPrice.dstGasPriceInWei.mul(dstConfig.gasPerByte).mul(dstPrice.dstPriceRatio).div(10**10); }
 

Oracle Fee

Oracel fee is paid to oracle to publish the receipt root on the target chain.
UltraLightNode uses uaConfig.oracle to calculate oracle fee. The default oracle(VerifierNetwork) on Ethereum is 0xD56e4eAb23cb81f43168F9F45211Eb027b9aC7cc. The VerifierNetwork.sol calls workerFeeLib(VerifierFeeLib.sol) to estimate gas, the VerifierFeeLib.sol is on 0xdeA04ef31C4B4FDf31CB58923F37869739280d49.
 
Note that in the x-message send process, the Oracle(VerifierNetwork.sol) uses the getFeeOnSend to get the required fee. The difference between getFeeOnSend and getFeeis that the former need value to pay for the price.
//LayerZero/V1/contract/contracts/UltraLightNodeV2.sol // returns the native fee the UA pays to cover fees function estimateFees(uint16 _dstChainId, address _ua, bytes calldata _payload, bool _payInZRO, bytes calldata _adapterParams) external view override returns (uint nativeFee, uint zroFee) { ... // Oracle Fee address ua = _ua; // stack too deep uint oracleFee = ILayerZeroOracleV2(uaConfig.oracle).getFee(_dstChainId, uaConfig.outboundProofType, uaConfig.outboundBlockConfirmations, ua); ... }
//VerifierNetwork.sol struct FeeParams { address priceFeed; uint32 dstEid; uint64 confirmations; address sender; uint64 quorum; uint16 defaultMultiplierBps; } /// @dev to support ULNv2 /// @dev getFee can revert if _sender doesn't pass ACL /// @param _dstEid destination EndpointId /// @param //_outboundProofType outbound proof type /// @param _confirmations block confirmations /// @param _sender message sender address function getFee( uint16 _dstEid, uint16 /*_outboundProofType*/, uint64 _confirmations, address _sender ) public view onlyAcl(_sender) returns (uint fee) { IVerifierFeeLib.FeeParams memory params = IVerifierFeeLib.FeeParams( priceFeed, _dstEid, _confirmations, _sender, quorum, defaultMultiplierBps ); return IVerifierFeeLib(workerFeeLib).getFee(params, dstConfig[_dstEid], bytes("")); } /// @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 memory _params, IVerifier.DstConfig memory _dstConfig, bytes memory /* _options */ ) external payable returns (uint) { uint callDataSize = _getCallDataSize(_params.quorum); // for future versions where priceFeed charges a fee // uint priceFeedFee = ILayerZeroPriceFeed(_params.priceFeed).getFee(_params.dstEid, callDataSize, _dstConfig.gas); // (uint fee, , , uint128 nativePriceUSD) = ILayerZeroPriceFeed(_params.priceFeed).estimateFeeOnSend{ // value: priceFeedFee // }(_params.dstEid, callDataSize, _dstConfig.gas); (uint fee, , , uint128 nativePriceUSD) = ILayerZeroPriceFeed(_params.priceFeed).estimateFeeOnSend( _params.dstEid, callDataSize, _dstConfig.gas ); return _applyPremium( fee, _dstConfig.multiplierBps, _params.defaultMultiplierBps, _dstConfig.floorMarginUSD, nativePriceUSD ); }

LayerZero fee

This is protocol fee. UltraLightNodeV2 calls the treasuryContract.sol to get Fee. There are two options to pay the protocol fee, one is to pay in ZRO, one is to pay some percent of sum of relayerFee and oracleFee. Currently, the zroEnabled is not enabled and the nativeBP is zero, so there is no protocol fee.
//LayerZero/V1/contract/contracts/UltraLightNodeV2.sol // returns the native fee the UA pays to cover fees function estimateFees(uint16 _dstChainId, address _ua, bytes calldata _payload, bool _payInZRO, bytes calldata _adapterParams) external view override returns (uint nativeFee, uint zroFee) { ... // LayerZero Fee uint protocolFee = treasuryContract.getFees(_payInZRO, relayerFee, oracleFee); _payInZRO ? zroFee = protocolFee : nativeFee = protocolFee; ... }
uint public zroFee; bool public feeEnabled; bool public zroEnabled; //LayerZero/V1/contract/contracts/TreasuryV2.sol function getFees(bool payInZro, uint relayerFee, uint oracleFee) external view override returns (uint) { if (feeEnabled) { if (payInZro) { require(zroEnabled, "LayerZero: ZRO is not enabled"); return zroFee; } else { return relayerFee.add(oracleFee).mul(nativeBP).div(10000); } } return 0; }
 

AdapterParams

AdapterParams are encoded data passed to Relayer to execute tx correctly.
The encoded data can include information like:
  • tx type
  • extra gas to send the tx
  • whether to carry native token in the tx
LayerZero use bytes type to include arbitrary data to faciliate later upgrade of tx type.
In the _getPrices function in RelayerV2, to calculate the execution fee of the tx, the _adapterParameters is decoded to get necessary information.
// V1/contract/contracts/RelayerV2.sol // txType 1 // bytes [2 32 ] // fields [txType extraGas] // txType 2 // bytes [2 32 32 bytes[] ] // fields [txType extraGas dstNativeAmt dstNativeAddress] // User App Address is not used in this version function _getPrices(uint16 _dstChainId, uint16 _outboundProofType, address, bytes memory _adapterParameters) internal view returns (uint basePrice, uint pricePerByte) { require(!paused, "Admin: paused"); // decoding the _adapterParameters - reverts if type 2 and there is no dstNativeAddress require(_adapterParameters.length == 34 || _adapterParameters.length > 66, "Relayer: wrong _adapterParameters size"); uint16 txType; uint extraGas; assembly { txType := mload(add(_adapterParameters, 2)) extraGas := mload(add(_adapterParameters, 34)) } require(extraGas > 0, "Relayer: gas too low"); require(txType == 1 || txType == 2, "Relayer: unsupported txType"); DstPrice storage dstPrice = dstPriceLookup[_dstChainId]; DstConfig storage dstConfig = dstConfigLookup[_dstChainId][_outboundProofType]; uint totalRemoteToken; // = baseGas + extraGas + requiredNativeAmount if (txType == 2) { uint dstNativeAmt; assembly { dstNativeAmt := mload(add(_adapterParameters, 66)) } require(dstConfig.dstNativeAmtCap >= dstNativeAmt, "Relayer: dstNativeAmt too large"); totalRemoteToken = totalRemoteToken.add(dstNativeAmt); } // remoteGasTotal = dstGasPriceInWei * (baseGas + extraGas) uint remoteGasTotal = dstPrice.dstGasPriceInWei.mul(dstConfig.baseGas.add(extraGas)); totalRemoteToken = totalRemoteToken.add(remoteGasTotal); // tokenConversionRate = dstPrice / localPrice // basePrice = totalRemoteToken * tokenConversionRate basePrice = totalRemoteToken.mul(dstPrice.dstPriceRatio).div(10**10); // pricePerByte = (dstGasPriceInWei * gasPerBytes) * tokenConversionRate pricePerByte = dstPrice.dstGasPriceInWei.mul(dstConfig.gasPerByte).mul(dstPrice.dstPriceRatio).div(10**10); }