Skip to main content
Version: Endpoint V2 Docs

Quickstart - Create Your First Omnichain App

This guide will walk you through the process of sending a simple cross-chain message using LayerZero, designed to be a beginner's first step into the world of omnichain applications. This example will utilize a simplified OApp contract to demonstrate the basic principles of sending and receiving messages across different blockchains.

Introduction

LayerZero enables seamless communication between different blockchain networks. With LayerZero, you can have an interaction on one blockchain (say, Ethereum) automatically trigger a reaction on another (like Arbitrum), all without relying on a central authority to relay that trigger.

OApp Example OApp Example

This guide will walk you through the process of setting up and using a simplified OApp contract to send messages across chains.

Prerequisites

Before getting started, make sure you have:

  • Node.js and NPM installed
  • Basic understanding of Solidity and smart contracts
  • Testnet funds for deploying contracts

Creating an OApp

Project Setup

LayerZero provides create-lz-oapp, a CLI Toolkit designed to streamline the process of building, testing, deploying and configuring omnichain applications (OApps).

create-lz-oapp is an npx package that creates a Node.js project with both the Hardhat and Foundry development frameworks installed, allowing developers to build from any LayerZero Contract Standards.

To start, create a new project:

npx create-lz-oapp@latest

Following this, a simple project creation wizard will guide you through setting up a project template.

Choose OApp as your example starting point when prompted and a package manager of your choice.

This will initialize a repo with example contracts, cross-chain unit tests for sample contracts, custom LayerZero configuration files, deployment scripts, and more.

OApp Smart Contract

Review the MyOApp.sol contract to see how it implements the OApp contract standard.

No need to change anything in this file at this point, but it's good to know how sending and receiving messages works.

// contracts/MyOApp.sol

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.22;

import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol";
import { OApp, MessagingFee, Origin } from "@layerzerolabs/oapp-evm/contracts/oapp/OApp.sol";
import { MessagingReceipt } from "@layerzerolabs/oapp-evm/contracts/oapp/OAppSender.sol";

contract MyOApp is OApp {
constructor(address _endpoint, address _delegate) OApp(_endpoint, _delegate) Ownable(_delegate) {}

// This is where the message will be stored after it is received on the destination chain
string public data = "Nothing received yet.";

/**
* @notice Sends a message from the source chain to a destination chain.
* @param _dstEid The endpoint ID of the destination chain.
* @param _message The message string to be sent.
* @param _options Additional options for message execution.
* @dev Encodes the message as bytes and sends it using the `_lzSend` internal function.
* @return receipt A `MessagingReceipt` struct containing details of the message sent.
*/
function send(
uint32 _dstEid,
// The message to be sent to the destination chain
string memory _message,
bytes calldata _options
) external payable returns (MessagingReceipt memory receipt) {
bytes memory _payload = abi.encode(_message);
receipt = _lzSend(_dstEid, _payload, _options, MessagingFee(msg.value, 0), payable(msg.sender));
}

/**
* @notice Quotes the gas needed to pay for the full omnichain transaction in native gas or ZRO token.
* @param _dstEid Destination chain's endpoint ID.
* @param _message The message.
* @param _options Message execution options (e.g., for sending gas to destination).
* @param _payInLzToken Whether to return fee in ZRO token.
* @return fee A `MessagingFee` struct containing the calculated gas fee in either the native token or ZRO token.
*/
function quote(
uint32 _dstEid,
string memory _message,
bytes memory _options,
bool _payInLzToken
) public view returns (MessagingFee memory fee) {
bytes memory payload = abi.encode(_message);
fee = _quote(_dstEid, payload, _options, _payInLzToken);
}

/**
* @dev Internal function override to handle incoming messages from another chain.
* @dev _origin A struct containing information about the message sender.
* @dev _guid A unique global packet identifier for the message.
* @param payload The encoded message payload being received.
*
* @dev The following params are unused in the current implementation of the OApp.
* @dev _executor The address of the Executor responsible for processing the message.
* @dev _extraData Arbitrary data appended by the Executor to the message.
*
* Decodes the received payload and processes it as per the business logic defined in the function.
*/
function _lzReceive(
Origin calldata /*_origin*/,
bytes32 /*_guid*/,
bytes calldata payload,
address /*_executor*/,
bytes calldata /*_extraData*/
) internal override {
data = abi.decode(payload, (string));
}
}

Configuration

Update your hardhat.config.ts file to include the networks you want to deploy to:

networks: {
'avalanche-testnet': {
eid: EndpointId.AVALANCHE_V2_TESTNET,
url: process.env.RPC_URL_FUJI || 'https://rpc.ankr.com/avalanche_fuji',
accounts,
},
'amoy-testnet': {
eid: EndpointId.AMOY_V2_TESTNET,
url: process.env.RPC_URL_AMOY || 'https://polygon-amoy-bor-rpc.publicnode.com',
accounts,
},
}
TIP: Choose Less Congested Networks

Deploying to Sepolia can be unreliable due to high gas prices and high network congestion. Avalanche and Polygon testnets are more stable and predictable. If you need gas for these test networks, you can try one of these faucets: Quicknode, Chainlink.

Rename .env.example file to .env and update it with needed configurations:

PRIVATE_KEY = your_private_key; // Required
RPC_URL_FUJI = your_fuji_rpc; // Optional but recommended
RPC_URL_AMOY = your_amoy_rpc; // Optional but recommended

At a minimum, you need to have the PRIVATE_KEY. RPC URLs are optional, but strongly recommended. If you don't provide them, public RPCs will be used, but public RPCs can be unreliable or slow, leading to long waiting times for transactions to be confirmed or, at worst, cause your transactions to fail.

Deploying Contracts

Before deploying, fund the address you're deploying from with the corresponding chains' native tokens. In this case, you need to have AVAX on Avalanche and POL on Polygon testnets.

Deploy your contracts using the LayerZero CLI:

npx hardhat lz:deploy

You will be presented with a list of networks to deploy to. If you have updated your hardhat.config.ts according to instructions above, you should have two networks already selected (amoy-tesnet and avalanche-testnet).

If everything is set up correctly, you should see output similar to this:

info:    Compiling your hardhat project
Nothing to compile
✔ Which networks would you like to deploy? › amoy-testnet, avalanche-testnet
✔ Which deploy script tags would you like to use? …
info: Will deploy 2 networks: amoy-testnet, avalanche-testnet
warn: Will use all deployment scripts
✔ Do you want to continue? … yes
Network: amoy-testnet
Deployer: 0x498098ca1b7447fC5035f95B80be97eE16F82597
Network: avalanche-testnet
Deployer: 0x498098ca1b7447fC5035f95B80be97eE16F82597
Deployed contract: MyOApp, network: avalanche-testnet, address: 0xC7c2c92b55342Df0c7F51D4dE3f02167466FacCC
Deployed contract: MyOApp, network: amoy-testnet, address: 0x0538A4ED0844583d876c29f80fB97c0f747968ce
info: ✓ Your contracts are now deployed

MyOApp contract is now deployed to both networks.

Deployer and deployed contract addresses will be different for your project.

Note the deployed contract addresses, we will need them later.

Configuration and wiring

Now we are ready to connect (wire) the contracts across chains. For that, we need to configure the layerzero.config.ts file to tell which chains should be wired and able to talk to each other. In our case, it's only two chains, but you can have as many as you want.

Modify your layerzero.config.ts file to include the chains you deployed to:

// layerzero.config.ts
import {EndpointId} from '@layerzerolabs/lz-definitions';

import type {OAppOmniGraphHardhat, OmniPointHardhat} from '@layerzerolabs/toolbox-hardhat';

const fujiContract: OmniPointHardhat = {
eid: EndpointId.AVALANCHE_V2_TESTNET,
contractName: 'MyOApp',
};

const amoyContract: OmniPointHardhat = {
eid: EndpointId.AMOY_V2_TESTNET,
contractName: 'MyOApp',
};

const config: OAppOmniGraphHardhat = {
contracts: [
{
contract: fujiContract,
},
{
contract: amoyContract,
},
],
connections: [
{
from: fujiContract,
to: amoyContract,
},
{
from: amoyContract,
to: fujiContract,
},
],
};

export default config;

Now we can wire the contracts using:

npx hardhat lz:oapp:wire --oapp-config layerzero.config.ts

This script will check all the configurations for each pathway, ask you if you would like to preview the transactions, show the transaction details before execution, and execute the transactions when you confirm. The final output will look like this:

info:    Successfully sent 2 transactions
info: ✓ Your OApp is now configured

To verify that the contracts are wired correctly, you can run:

npx hardhat lz:oapp:peers:get --oapp-config layerzero.config.ts

This will output the peers for each contract, showing the contracts that are able to send and receive messages to each other.

┌───────────────────┬───────────────────┬──────────────┐
│ from → to │ avalanche-testnet │ amoy-testnet │
├───────────────────┼───────────────────┼──────────────┤
│ avalanche-testnet │ ∅ │ ✓ │
├───────────────────┼───────────────────┼──────────────┤
│ amoy-testnet │ ✓ │ ∅ │
└───────────────────┴───────────────────┴──────────────┘

✓ - Connected
⤫ - Not Connected
∅ - Ignored

Seems like everything is wired correctly. Time to send the first cross-chain message!

Sending Your First Message

Now, you need to prepare a transaction that sends a message across the configured LayerZero channel. Using the contract instance that you deployed on Avalanche, you will call the send function on the contract, providing the required parameters: the source network, destination network and the message.

To make it easier, let's create a hardhat task to do that.

Create a new file tasks/sendMessage.ts and add the following code:

// tasks/sendMessage.ts

import {task} from 'hardhat/config';
import {HardhatRuntimeEnvironment} from 'hardhat/types';

import {Options} from '@layerzerolabs/lz-v2-utilities';

export default task('sendMessage', 'Send a message to the destination chain')
.addParam('dstNetwork', 'The destination network name (from hardhat.config.ts)')
.addParam('message', 'The message to send')
.setAction(async (taskArgs, hre: HardhatRuntimeEnvironment) => {
const {message, dstNetwork} = taskArgs;
const [signer] = await hre.ethers.getSigners();

// Get destination network's EID
const dstNetworkConfig = hre.config.networks[dstNetwork];
const dstEid = dstNetworkConfig.eid;

// Get current network's EID
const srcNetworkConfig = hre.config.networks[hre.network.name];
const srcEid = srcNetworkConfig?.eid;

console.log('Sending message:');
console.log('- From:', signer.address);
console.log('- Source network:', hre.network.name, srcEid ? `(EID: ${srcEid})` : '');
console.log('- Destination:', dstNetwork || 'unknown network', `(EID: ${dstEid})`);
console.log('- Message:', message);

const myOApp = await hre.deployments.get('MyOApp');
const contract = await hre.ethers.getContractAt('MyOApp', myOApp.address, signer);

// Add executor options with gas limit
const options = Options.newOptions().addExecutorLzReceiveOption(200000, 0).toBytes();

// Get quote for the message
console.log('Getting quote...');
const quotedFee = await contract.quote(dstEid, message, options, false);
console.log('Quoted fee:', hre.ethers.utils.formatEther(quotedFee.nativeFee));

// Send the message
console.log('Sending message...');
const tx = await contract.send(dstEid, message, options, {value: quotedFee.nativeFee});

const receipt = await tx.wait();
console.log('🎉 Message sent! Transaction hash:', receipt.transactionHash);
console.log(
'Check message status on LayerZero Scan: https://testnet.layerzeroscan.com/tx/' +
receipt.transactionHash,
);
});

We also need to import the task in our hardhat.config.ts file:

// hardhat.config.ts
// (...)
import {EndpointId} from '@layerzerolabs/lz-definitions';
import './tasks/sendMessage'; // Import the task

Now you can send a cross-chain message, for example from Avalanche to Amoy, using:

npx hardhat sendMessage --network avalanche-testnet --dst-network amoy-testnet --message "Hello Omnichain World (sent from Avalanche)"

This will output the transaction hash and a link to the LayerZero Scan to verify the message.

Sending message:
- From: 0x498098ca1b7447fC5035f95B80be97eE16F82597
- Source network: avalanche-testnet (EID: 40106)
- Destination: amoy-testnet (EID: 40267)
- Message: Hello Omnichain World (sent from Avalanche)
Getting quote...
Quoted fee: 0.004605311339306711
Sending message...
🎉 Message sent! Transaction hash: 0x47bd60f2710c2ec5a496c55c9763bd87fd4c599b541ad1287540fce9852ede65
Check message status on LayerzeRo Scan: https://testnet.layerzeroscan.com/tx/0x47bd60f2710c2ec5a496c55c9763bd87fd4c599b541ad1287540fce9852ede65

Congratulations! You've just sent your first cross-chain message using LayerZero.

Now let's have a closer look at the message and how it was received on the destination chain.

Verifying Receipts

The message will be stored in the data variable of the MyOApp contract on the destination chain. Remember how we set the data variable to "Nothing received yet." in the MyOApp.sol contract?

// contracts/MyOApp.sol
// (...)
string public data = "Nothing received yet.";

Now this data variable will be updated on the destination chain with the message we sent.

We can verify this by calling the data getter function on the MyOApp contract on the destination chain, but first, let's have a look at the transaction on the LayerZero Scan.

Click on the LayerZero Scan link in the output of the transaction to get all the details of the message we just sent.

LayerZero Scan Transaction Status

There's a lot of useful information here. Let's focus on a few key details:

  1. Status: The transaction status is Delivered. If you're checking the status of the message immediately after sending it, it might still be in Inflight status. Just wait a few seconds and it should be automatically updated.
  2. Message Payload: All the parameters of our cross-chain message are included here, including the message itself, encoded as bytes.
  3. Transaction Fee: This is how much we paid to send the message cross-chain.
  4. OApp Configuration: This is the configuration of the MyOApp contract both on the source and destination chains. We used a lot of the default configurations, but you can customize them to your needs later on.
  5. Destination Omnichain Application: This is the address of the MyOApp contract on the destination chain. You can click on the globe icon next to it to see the contract on the destination chain.

You can click around the transaction details to learn more about the message passing process.

When going to the destination chain (step 5 above), and clicking the "Contract" button and then "Read" button, you can see the message in the data variable of the MyOApp contract.

OmnichainMessage Successful

We're on Polygon Amoy, and we have successfully received the message from Avalanche Fuji. Mission accomplished!

Important Notes

  • Always ensure you have sufficient gas tokens on both source and destination chains
  • Double check endpoint IDs and contract addresses when setting peers
  • Monitor LayerZero Scan for message status

Next Steps

You have now successfully set up and used a simplified OApp contract to send a message across two different blockchains using LayerZero. This guide serves as a foundational example of the capabilities of LayerZero's cross-chain messaging. From here, you can explore more advanced features and build more complex omnichain applications.

Explore Contract Standards

  • Omnichain Token: Create an Omichain Fungible Token that works across chains.
  • Omnichain NFT: Build an Omnichain Non-Fungible Token (ONFT) collection that works across chains.
  • Omnichain Read: Read external state from other chains and perform calculations, using LayerZero Read.

Understand the Protocol and Core Concepts