Skip to main content
Version: Endpoint V1

LzApp Walkthrough

For a developer to send a customized cross-chain transaction from a source network to a destination network a few things need to happen:

  1. Write: A LayerZero (LZ) Omnichain smart contract must be written to estimate cross-chain fees for transactions, as well as compose send and receive logic.
  2. Deploy: This smart contract must be deployed on both the source and destination chains, specifying the LZ endpoint address associated with each chain in the contract's constructor argument.
  3. LZ endpoints contain the functionality needed for your smart contracts to communicate with an UltraLightNode (ULN), a low-cost mechanism designed to validate and relay cross-chain messages.
  4. Set Trusted Remotes: A trusted remote (contract address) must be configured for both the source and destination chain contracts after deployment, enabling them to authenticate that incoming messages are from the appropriate source contract.
  5. Estimate Fees and Send Transactions: Estimate how much gas to send using estimateFees and send a simple message to your destination chain

Write

Three functions are crucial for an omnichain user application to successfully send and receive cross-chain messages.

estimateFees()

// @return nativeFee The estimated fee required denominated in the native chain's gas token.
// The dstEndpointId refers to the LayerZero endpointId
function estimateFees(uint16 dstEndpointId, bytes calldata adapterParams, string memory _message) public view returns (uint nativeFee, uint zroFee) {

// Input the message you plan to send.
bytes memory payload = abi.encode(_message);

// Call the estimateFees function on the lzEndpoint contract.
// This function estimates the fees required on the source chain, the destination chain, and by the LayerZero protocol.
return lzEndpoint.estimateFees(dstEndpointId, address(this), payload, false, adapterParams);
}

send()

// This function is called to send the data string to the destination.
// It's payable, so that we can use our native gas token to pay for gas fees.

// The dstEndpointId refers to the LayerZero EndpointId
function send(uint16 dstEndpointId, bytes calldata adapterParams, string memory _message) public payable {

// The message is encoded as bytes and stored in the "payload" variable.
bytes memory payload = abi.encode(_message);

// The data is sent using the parent contract's _lzSend function.
_lzSend(dstEndpointId, payload, payable(msg.sender), address(0x0), adapterParams, msg.value);
}

lzReceive()

There are two ways to implement lzReceive within a specific LzApp implementation. You must choose one of these two implementations based on your Application's needs.

  1. _blockingLzReceive
    1. The _blockingLzReceive default functionality in LayerZero ensures that messages are processed in the order they were sent from a source User Application (srcUA) to all destination User Applications (dstUA) on the same chain. This mechanism can lead to a message being blocked if it is stored and not processed in sequence, thereby maintaining the integrity of the ordered delivery system.
      // This function is called when data is received. It overrides the equivalent function in the parent contract.
      function _blockingLzReceive(uint16, bytes memory, uint64, bytes memory _payload) internal override {
      // The LayerZero _payload (message) is decoded as a string and stored in the "data" variable.
      data = abi.decode(_payload, (string));
      }
  2. _nonBlockingLzReceive
    1. The _nonBlockingLzReceive function is used in user applications to handle incoming messages in a way that avoids blocking the message queue. It does this by catching errors or exceptions locally, allowing for future retries without disrupting the flow of messages at the destination LayerZero Endpoint. In order to use
      // This function is called when data is received. It overrides the equivalent function in the parent contract.
      function _nonBlockingLzReceive(uint16, bytes memory, uint64, bytes memory _payload) internal override {
      // The LayerZero _payload (message) is decoded as a string and stored in the "data" variable.
      data = abi.decode(_payload, (string));
      }

Deploy

The estimateFees and send functions leverage inherited methods from LzApp to engage with the LZ endpoint. This endpoint must be defined as a parameter in the contract's constructor, as shown below:

import "@layerzerolabs/solidity-examples/contracts/lzApp/LzApp.sol";

// To use _nonBlockingLzReceive you must inherit NonBlockingLzApp instead of LzApp.
contract MyLzApp is LzApp {

constructor(address _lzEndpoint) LzApp(_lzEndpoint) {}

}

Once your constructor is properly defined, you can deploy your contracts by running the following script with HardHat CLI:

JavaScript

// deploy.js

const hre = require('hardhat');

async function main() {
const MyLzApp = await hre.ethers.getContractFactory('MyLzApp');

const endpointAddress = '0x00000000000000000000000000000'; // Replace with the given chain's endpoint address

// Deploy the contract with the specified constructor argument

const lzApplication = await MyLzApp(endpointAddress);

// Wait for the deployment to finish
await lzApplication.waitForDeployment();
console.log('Your LZ Application deployed to:', await lzApplication.getAddress());
}

// We recommend this pattern to be able to use async/await everywhere
// and properly handle errors.
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});

Hardhat CLI

npx hardhat run scripts/deploy.js --network networkA
npx hardhat run scripts/deploy.js --network networkB

Set Trusted Remotes

When a message is sent from the source contract on one blockchain, the destination contract on another blockchain must be pre-configured to recognize and accept messages only from a specific, trusted source contract address. This setup ensures that when the destination contract receives a message, it can authenticate that it's indeed from the correct source. Trusted remotes can be set by calling the following function:

// This function sets the trusted path for the cross-chain communication
// The _remoteChainId refers to the LayerZero EndpointId

function setTrustedRemoteAddress(uint16 _remoteChainId, bytes calldata _remoteAddress) external onlyOwner {
// We pack the remote address with the local address to create a unique chain path
trustedRemoteLookup[_remoteChainId] = abi.encodePacked(_remoteAddress, address(this));
emit SetTrustedRemoteAddress(_remoteChainId, _remoteAddress);
}

JavaScript

// using ethers v5

const remoteAddress = ethers.utils.hexlify(remoteAddress);
const remoteEndpointId = 101; // Replace with your remote LayerZero endpointId

const tx = await yourOAppContract1.setTrustedRemoteAddress(remoteEndpointId, remoteAddress);
await tx.wait();

Once your trusted remotes are set your contracts are ready to send and receive cross-chain transactions!

To implement custom parameters that enable more nuanced configurability when wiring up your contracts, please see Wire Up Configration.

Estimate Fees and Send Transactions

Every cross-chain transaction has a different fee quote associated with it based on 3 inputs:

  1. dstEndpointId: The destination LayerZero endpointID
    • Testnet EndpointIds can be found here
    • Mainnet EndpointIds can be found here
  2. adapterParams: A bytes array that contains custom instructions for how a LZ Relayer should transmit the transaction. Custom instructions include:
    • The upper limit on how much destination gas to spend
    • Instructing the relayer to airdrop native currency to a specified wallet address
  3. _message: This is the message you intend to send to your destination chain and contract
caution

Make sure the wallet you're sending funds from is properly funded!

These inputs are passed into the estimateFees function which returns a quote. The quote is then passed as the msg.value of your send transaction. This cross-chain transaction flow can be packaged into a script and ran via Hardhat CLI like so:

JavaScript

// sendTx.js
// Using ethers v5

const hre = require('hardhat');
async function testMessaging() {
const ethers = hre.ethers;

// Define your contract

const MyLzAppFactory = await ethers.getContractFactory('MyLzApp');
const lzApp = MyLzAppFactory.attach('0x00000000000'); // Replace with your source chain OApp's address

// Define your input parameters

const dstEndpointId = '101'; // Replace with the destination LayerZero endpointId
const adapterParams = ethers.utils.solidityPack(['uint16', 'uint256'], [1, 200000]); // Passing 200000 as a default for gas

// Set the content of your cross-chain message, that will be encoded and stored on your destination contract via lzReceive
const message = 'Transaction Passed!';

// Run estimateFees to get your quote

const [nativeFee, zroFee] = await lzApp.estimateFees(dstEndpointId, false, adapterParams);
console.log(`Estimated Fees: Native - ${nativeFee}, ZRO - ${zroFee}`);

// Send your message passing your nativeFee as the msg.value
// We recommend putting a 20-30% buffer on your nativeFee to ensure transaction success

const tx = await lzApp.send(dstEndpointId, adapterParams, message, {value: nativeFee});
await tx.wait();

console.log('Message sent successfully', tx);
}

testMessaging()
.then(() => console.log('Messaging test completed'))
.catch((error) => console.error('Error in messaging test:', error));

Hardhat CLI

npx hardhat run scripts/sendTx.js --network networkA

Examine the transaction on LayerZero Scan​

Finally, let's see what's happening in our transaction. Take your transaction hash (printed in your console logs from the step before) and paste it into: https://testnet.layerzeroscan.com/.

You should see Status: Delivered, confirming your message has been delivered to its destination using LayerZero.

Congrats, you just executed your first Omnichain transaction! 🥳