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.
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,
},
}
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 │ ✓ │ ∅ │