DEV Community

Cover image for Sending messages from Abstract to other chains using LayerZero V2
Taylor Caldwell
Taylor Caldwell

Posted on

Sending messages from Abstract to other chains using LayerZero V2

This tutorial will guide you through the process of sending cross-chain message data from an Abstract smart contract to another smart contract on a different chain using LayerZero V2.


Objectives

By the end of this tutorial you should be able to do the following:

  • Set up a smart contract project for Abstract using Foundry
  • Install the LayerZero smart contracts as a dependency
  • Use LayerZero to send messages and from smart contracts on Abstract to smart contracts on different chains
  • Deploy and test your smart contracts on Abstract testnet

Prerequisites

Foundry

This tutorial requires you to have Foundry installed.

  • From the command-line (terminal), run: curl -L https://foundry.paradigm.xyz | bash
  • Then run foundryup, to install the latest (nightly) build of Foundry

For more information, see the Foundry Book installation guide.

Metamask Wallet

In order to deploy a smart contract, you will first need a crypto wallet. You can create a wallet by downloading the Metamask browser extension.

Wallet funds

Deploying contracts to the blockchain requires a gas fee. Therefore, you will need to fund your wallet with ETH to cover those gas fees.

For this tutorial, you will be deploying a contract to the Abstract Sepolia test network. You can fund your wallet with Abstract Sepolia ETH using a bridge or faucet.


What is LayerZero?

LayerZero is an interoperability protocol that allows developers to build applications (and tokens) that can connect to multiple blockchains. LayerZero defines these types of applications as "omnichain" applications.

The LayerZero protocol is made up of immutable on-chain Endpoints, a configurable Security Stack, and a permissionless set of Executors that transfer messages between chains.

High-level concepts

Endpoints

Endpoints are immutable LayerZero smart contracts that implement a standardized interface for your own smart contracts to use and in order to manage security configurations and send and receive messages.

Security Stack (DVNs)

The Security Stack is a configurable set of required and optional Decentralized Verifier Networks (DVNs). The DVNs are used to verify message payloads to ensure integrity of your application's messages.

Executors

Executors are responsible for initiating message delivery. They will automatically execute the lzReceive function of the endpoint on the destination chain once a message has been verified by the Security Stack.


Creating a project

Before you begin, you need to set up your smart contract development environment by creating a Foundry project.

To create a new Foundry project, first create a new directory:

mkdir myproject
Enter fullscreen mode Exit fullscreen mode

Then run:

cd myproject
forge init
Enter fullscreen mode Exit fullscreen mode

This will create a Foundry project with the following basic layout:

.
├── foundry.toml
├── script
├── src
└── test
Enter fullscreen mode Exit fullscreen mode

Info: You can delete the src/Counter.sol, test/Counter.t.sol, and script/Counter.s.sol boilerplate files that were generated with the project, as you will not be needing them.


Installing the LayerZero smart contracts

To use LayerZero within your Foundry project, you need to install the LayerZero smart contracts and their dependencies using forge install.

To install LayerZero smart contracts and their dependencies, run the following commands:

forge install GNSPS/solidity-bytes-utils --no-commit
forge install OpenZeppelin/openzeppelin-contracts@v4.9.4 --no-commit
forge install LayerZero-Labs/LayerZero-v2 --no-commit
Enter fullscreen mode Exit fullscreen mode

Once installed, update your foundry.toml file by appending the following lines:

remappings = [
    '@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts',
    'solidity-bytes-utils/=lib/solidity-bytes-utils',
    '@layerzerolabs/lz-evm-oapp-v2/=lib/LayerZero-v2/oapp',
    '@layerzerolabs/lz-evm-protocol-v2/=lib/LayerZero-v2/protocol',
    '@layerzerolabs/lz-evm-messagelib-v2/=lib/LayerZero-v2/messagelib',
]

Enter fullscreen mode Exit fullscreen mode

Getting started with LayerZero

LayerZero provides a smart contract standard called OApp that is intended for omnichain messaging and configuration.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import { OAppSender } from "./OAppSender.sol";
import { OAppReceiver, Origin } from "./OAppReceiver.sol";
import { OAppCore } from "./OAppCore.sol";

abstract contract OApp is OAppSender, OAppReceiver {
   constructor(address _endpoint) OAppCore(_endpoint, msg.sender) {}

   function oAppVersion() public pure virtual returns (uint64 senderVersion, uint64 receiverVersion) {
       senderVersion = SENDER_VERSION;
       receiverVersion = RECEIVER_VERSION;
   }
}
Enter fullscreen mode Exit fullscreen mode

Info: You can view the source code for this contract on GitHub.

To get started using LayerZero, developers simply need to inherit from the OApp contract, and implement the following two inherited functions:

  • _lzSend: A function used to send an omnichain message
  • _lzReceive: A function used to receive an omnichain message

In this tutorial, you will be implementing the OApp standard into your own project to add the capability to send messages from a smart contract on Abstract to a smart contract on Arbitrum.

Info: An extension of the OApp contract standard known as OFT is also available for supporting omnichain fungible token transfers.

Info: For more information on transferring tokens across chains using LayerZero, visit the LayerZero documentation.


Writing the smart contract

To get started, create a new Solidity smart contract file in your project's src/ directory named ExampleContract.sol, and add the following content:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import { OApp, Origin, MessagingFee } from "@layerzerolabs/lz-evm-oapp-v2/contracts/oapp/OApp.sol";

contract ExampleContract is OApp {
   constructor(address _endpoint, address _owner) OApp(_endpoint, _owner) {}
}
Enter fullscreen mode Exit fullscreen mode

The code snippet above defines a new smart contract named ExampleContract that extends the OApp contract standard.

The contract's constructor expects two arguments:

  • _endpoint: The LayerZero Endpoint address for the chain the smart contract is deployed to.
  • _owner: The address of the owner of the smart contract.

Info: LayerZero Endpoints are smart contracts that expose an interface for OApp contracts to manage security configurations and send and receive messages via the LayerZero protocol.

Implementing message sending (_lzSend)

To send messages to another chain, your smart contract must call the _lzSend function inherited from the OApp contract.

Add a new custom function named sendMessage to your smart contract that has the following content:

/// @notice Sends a message from the source chain to the destination chain.
/// @param _dstEid The endpoint ID of the destination chain.
/// @param _message The message to be sent.
/// @param _options The message execution options (e.g. gas to use on destination).
function sendMessage(uint32 _dstEid, string memory _message, bytes calldata _options) external payable {
   bytes memory _payload = abi.encode(_message); // Encode the message as bytes
   _lzSend(
       _dstEid,
       _payload,
       _options,
       MessagingFee(msg.value, 0), // Fee for the message (nativeFee, lzTokenFee)
       payable(msg.sender) // The refund address in case the send call reverts
   );
}
Enter fullscreen mode Exit fullscreen mode

The sendMessage function above calls the inherited _lzSend function, while passing in the following expected data:

Name Type Description
_dstEid uint32 The endpoint ID of the destination chain to send the message to.
_payload bytes The message (encoded) to send.
_options bytes Additional options when sending the message, such as how much gas should be used when receiving the message.
_fee MessagingFee The calculated fee for sending the message.
_refundAddress address The address that will receive any excess fee values sent to the endpoint in case the _lzSend execution reverts.

Implementing gas fee estimation (_quote)

As shown in the table provided in the last section, the _lzSend function expects an estimated gas fee to be provided when sending a message (_fee).

Therefore, sending a message using the sendMessage function of your contract, you first need to estimate the associated gas fees.

There are multiple fees incurred when sending a message across chains using LayerZero, including: paying for gas on the source chain, fees paid to DVNs validating the message, and gas on the destination chain. Luckily, LayerZero bundles all of these fees together into a single fee to be paid by the msg.sender, and LayerZero Endpoints expose a _quote function to estimate this fee.

Add a new function to your ExampleContract contract called estimateFee that calls the _quote function, as shown below:

/// @notice Estimates the gas associated with sending a message.
/// @param _dstEid The endpoint ID of the destination chain.
/// @param _message The message to be sent.
/// @param _options The message execution options (e.g. gas to use on destination).
/// @return nativeFee Estimated gas fee in native gas.
/// @return lzTokenFee Estimated gas fee in ZRO token.
function estimateFee(
   uint32 _dstEid,
   string memory _message,
   bytes calldata _options
) public view returns (uint256 nativeFee, uint256 lzTokenFee) {
   bytes memory _payload = abi.encode(_message);
   MessagingFee memory fee = _quote(_dstEid, _payload, _options, false);
   return (fee.nativeFee, fee.lzTokenFee);
}
Enter fullscreen mode Exit fullscreen mode

The estimateFee function above calls the inherited _quote function, while passing in the following expected data:

Name Type Description
_dstEid uint32 The endpoint ID of the destination chain the message will be sent to.
_payload bytes The message (encoded) that will be sent.
_options bytes Additional options when sending the message, such as how much gas should be used when receiving the message.
_payInLzToken bool Boolean flag for which token to use when returning the fee (native or ZRO token).

Info: Your contract’s estimateFee function should always be called immediately before calling sendMessage to accurately estimate associated gas fees.

Implementing message receiving (_lzReceive)

To receive messages on the destination chain, your smart contract must override the _lzReceive function inherited from the OApp contract.

Add the following code snippet to your ExampleContract contract to override the _lzReceive function:

/// @notice Entry point for receiving messages.
/// @param _origin The origin information containing the source endpoint and sender address.
///  - srcEid: The source chain endpoint ID.
///  - sender: The sender address on the src chain.
///  - nonce: The nonce of the message.
/// @param _guid The unique identifier for the received LayerZero message.
/// @param _message The payload of the received message.
/// @param _executor The address of the executor for the received message.
/// @param _extraData Additional arbitrary data provided by the corresponding executor.
function _lzReceive(
   Origin calldata _origin,
   bytes32 _guid,
   bytes calldata payload,
   address _executor,
   bytes calldata _extraData
   ) internal override {
       data = abi.decode(payload, (string));
       // other logic
}
Enter fullscreen mode Exit fullscreen mode

The overridden _lzReceive function receives the following arguments when receiving a message:

Name Type Description
_origin Origin The origin information containing the source endpoint and sender address.
_guid bytes32 The unique identifier for the received LayerZero message.
payload bytes The payload of the received message (encoded).
_executor address The address of the Executor for the received message.
_extraData bytes Additional arbitrary data provided by the corresponding Executor.

Note that the overridden method decodes the message payload, and stores the string into a variable named data that you can read from later to fetch the latest message.

Add the data field as a member variable to your contract:

contract ExampleContract is OApp {

    // highlight-next-line
    string public data;

    constructor(address _endpoint) OApp(_endpoint, msg.sender) {}
}
Enter fullscreen mode Exit fullscreen mode

Info: Overriding the _lzReceive function allows you to provide any custom logic you wish when receiving messages, including making a call back to the source chain by invoking _lzSend. Visit the LayerZero Message Design Patterns for common messaging flows.

Final code

Once you complete all of the steps above, your contract should look like this:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import { OApp, Origin, MessagingFee } from "@layerzerolabs/lz-evm-oapp-v2/contracts/oapp/OApp.sol";

contract ExampleContract is OApp {

 string public data;

  constructor(address _endpoint) OApp(_endpoint, msg.sender) {}

  /// @notice Sends a message from the source chain to the destination chain.
  /// @param _dstEid The endpoint ID of the destination chain.
  /// @param _message The message to be sent.
  /// @param _options The message execution options (e.g. gas to use on destination).
  function sendMessage(uint32 _dstEid, string memory _message, bytes calldata _options) external payable {
     bytes memory _payload = abi.encode(_message); // Encode the message as bytes
     _lzSend(
           _dstEid,
           _payload,
           _options,
           MessagingFee(msg.value, 0), // Fee for the message (nativeFee, lzTokenFee)
           payable(msg.sender) // The refund address in case the send call reverts
     );
  }

  /// @notice Estimates the gas associated with sending a message.
  /// @param _dstEid The endpoint ID of the destination chain.
  /// @param _message The message to be sent.
  /// @param _options The message execution options (e.g. gas to use on destination).
  /// @return nativeFee Estimated gas fee in native gas.
  /// @return lzTokenFee Estimated gas fee in ZRO token.
  function estimateFee(
     uint32 _dstEid,
     string memory _message,
     bytes calldata _options
  ) public view returns (uint256 nativeFee, uint256 lzTokenFee) {
     bytes memory _payload = abi.encode(_message);
     MessagingFee memory fee = _quote(_dstEid, _payload, _options, false);
     return (fee.nativeFee, fee.lzTokenFee);
  }

  /// @notice Entry point for receiving messages.
  /// @param _origin The origin information containing the source endpoint and sender address.
  ///  - srcEid: The source chain endpoint ID.
  ///  - sender: The sender address on the src chain.
  ///  - nonce: The nonce of the message.
  /// @param _guid The unique identifier for the received LayerZero message.
  /// @param _message The payload of the received message.
  /// @param _executor The address of the executor for the received message.
  /// @param _extraData Additional arbitrary data provided by the corresponding executor.
  function _lzReceive(
     Origin calldata _origin,
     bytes32 _guid,
     bytes calldata payload,
     address _executor,
     bytes calldata _extraData
     ) internal override {
        data = abi.decode(payload, (string));
  }
}
Enter fullscreen mode Exit fullscreen mode

Compiling the smart contract

Compile the smart contract to ensure it builds without any errors.

To compile your smart contract, run:

forge build
Enter fullscreen mode Exit fullscreen mode

Deploying the smart contract

Setting up your wallet as the deployer

Before you can deploy your smart contract to various chains you will need to set up a wallet to be used as the deployer.

To do so, you can use the cast wallet import command to import the private key of the wallet into Foundry's securely encrypted keystore:

cast wallet import deployer --interactive
Enter fullscreen mode Exit fullscreen mode

After running the command above, you will be prompted to enter your private key, as well as a password for signing transactions.

Caution: For instructions on how to get your private key from Metamask, visit the Metamask Support page.

To confirm that the wallet was imported as the deployer account in your Foundry project, run:

cast wallet list
Enter fullscreen mode Exit fullscreen mode

Setting up environment variables

To setup your environment, create an .env file in the home directory of your project, and add the RPC URLs and LayerZero Endpoint information for both Abstract Sepolia Testnet and Arbitrum Sepolia Testnet:

ABSTRACT_TESTNET_RPC="https://api.testnet.abs.xyz"
ABSTRACT_TESTNET_LZ_ENDPOINT=0x16c693A3924B947298F7227792953Cd6BBb21Ac8
ABSTRACT_TESTNET_LZ_ENDPOINT_ID=40313

ARBITRUM_TESTNET_RPC="https://sepolia-rollup.arbitrum.io/rpc"
ARBITRUM_TESTNET_LZ_ENDPOINT=0x6EDCE65403992e310A62460808c4b910D972f10f
ARBITRUM_TESTNET_LZ_ENDPOINT_ID=40231
Enter fullscreen mode Exit fullscreen mode

Once the .env file has been created, run the following command to load the environment variables in the current command line session:

source .env
Enter fullscreen mode Exit fullscreen mode

With your contract compiled and environment setup, you are now ready to deploy the smart contract to different networks.

Deploying the smart contract to Abstract Testnet

To deploy a smart contract using Foundry, you can use the forge create command. The command requires you to specify the smart contract you want to deploy, an RPC URL of the network you want to deploy to, and the account you want to deploy with.

Info: Your wallet must be funded with ETH on the Abstract Testnet and Arbitrum Testnet to cover the gas fees associated with the smart contract deployment. Otherwise, the deployment will fail.

To get testnet ETH, see the prerequisites.

To deploy the ExampleContract smart contract to the Abstract Sepolia testnet, run the following command:

forge create ./src/ExampleContract.sol:ExampleContract --rpc-url $ABSTRACT_TESTNET_RPC --constructor-args $ABSTRACT_TESTNET_LZ_ENDPOINT --account deployer
Enter fullscreen mode Exit fullscreen mode

When prompted, enter the password that you set earlier, when you imported your wallet's private key.

After running the command above, the contract will be deployed on the Abstract Sepolia testnet. You can view the deployment status and contract by using a block explorer.

Deploying the smart contract to Arbitrum Testnet

To deploy the ExampleContract smart contract to the Arbitrum Sepolia testnet, run the following command:

forge create ./src/ExampleContract.sol:ExampleContract --rpc-url $ARBITRUM_TESTNET_RPC --constructor-args $ARBITRUM_TESTNET_LZ_ENDPOINT --account deployer
Enter fullscreen mode Exit fullscreen mode

When prompted, enter the password that you set earlier, when you imported your wallet's private key.

After running the command above, the contract will be deployed on the Arbitrum Sepolia testnet. You can view the deployment status and contract by using the Arbitrum Sepolia block explorer.

Opening the messaging channels

Once your contract has been deployed to Abstract Sepolia and Arbitrum Sepolia, you will need to open the messaging channels between the two contracts so that they can send and receive messages from one another. This is done by calling the setPeer function on the contract.

The setPeer function expects the following arguments:

Name Type Description
_eid uint32 The endpoint ID of the destination chain.
_peer bytes32 The contract address of the OApp contract on the destination chain.

Setting the peers

Foundry provides the cast command-line tool that can be used to interact with deployed smart contracts and call their functions.

To set the peer of your ExampleContract contracts, you can use cast to call the setPeer function while providing the endpoint ID and address (in bytes) of the deployed contract on the respective destination chain.

To set the peer of the Abstract Sepolia contract to the Arbitrum Sepolia contract, run the following command:

cast send <ABSTRACT_TESTNET_CONTRACT_ADDRESS> --rpc-url $ABSTRACT_TESTNET_RPC "setPeer(uint32, bytes32)" $ARBITRUM_TESTNET_LZ_ENDPOINT_ID <ARBITRUM_TESTNET_CONTRACT_ADDRESS> --account deployer
Enter fullscreen mode Exit fullscreen mode

Info: Replace <ABSTRACT_TESTNET_CONTRACT_ADDRESS> with the contract address of your deployed ExampleContract contract on Abstract Sepolia, and<ARBITRUM_TESTNET_CONTRACT_ADDRESS> with the contract address (as bytes) of your deployed ExampleContract contract on Arbitrum Sepolia before running the provided cast command.

To set the peer of the Arbitrum Sepolia contract to the Abstract Sepolia contract, run the following command:

cast send <ARBITRUM_TESTNET_CONTRACT_ADDRESS> --rpc-url $ARBITRUM_TESTNET_RPC "setPeer(uint32, bytes32)" $ABSTRACT_TESTNET_LZ_ENDPOINT_ID <ABSTRACT_TESTNET_CONTRACT_ADDRESS> --account deployer
Enter fullscreen mode Exit fullscreen mode

Info: Replace <ARBITRUM_TESTNET_CONTRACT_ADDRESS> with the contract address of your deployed ExampleContract contract on Arbitrum Sepolia, and<ABSTRACT_TESTNET_CONTRACT_ADDRESS> with the contract address (as bytes) of your deployed ExampleContract contract on Abstract Sepolia before running the provided cast command.


Sending messages

Once peers have been set on each contract, they are now able to send and receive messages from one another.

Sending a message using the newly created ExampleContract contract can be done in three steps:

  1. Build message options to specify logic associated with the message transaction
  2. Call the estimateFee function to estimate the gas fee for sending a message
  3. Call the sendMessage function to send a message

Building message options

The estimateFee and sendMessage custom functions of the ExampleContract contract both require a message options (_options) argument to be provided.

Message options allow you to specify arbitrary logic as part of the message transaction, such as the gas amount the Executor pays for message delivery, the order of message execution, or dropping an amount of gas to a destination address.

LayerZero provides a Solidity library and TypeScript SDK for building these message options.

As an example, below is a Foundry script that uses OptionsBuilder from the Solidity library to generate message options (as bytes) that set the gas amount that the Executor will pay upon message delivery to 200000 wei:

pragma solidity ^0.8.0;

import {Script, console2} from "forge-std/Script.sol";
import { OptionsBuilder } from "@layerzerolabs/lz-evm-oapp-v2/contracts/oapp/libs/OptionsBuilder.sol";

contract OptionsScript is Script {
    using OptionsBuilder for bytes;

    function setUp() public {}

    function run() public {
        bytes memory options = OptionsBuilder.newOptions().addExecutorLzReceiveOption(200000, 0);
        console2.logBytes(options);
    }
}
Enter fullscreen mode Exit fullscreen mode

The output of this script results in:

0x00030100110100000000000000000000000000030d40
Enter fullscreen mode Exit fullscreen mode

For this tutorial, rather than building and generating your own message options, you can use the bytes output provided above.

Info: Covering all of the different message options in detail is out of scope for this tutorial. If you are interested in learning more about the different message options and how to build them, visit the LayerZero developer documentation.

Estimating the gas fee

Before you can send a message from your contract on Abstract Sepolia, you need to estimate the fee associated with sending the message. You can use the cast command to call the estimateFee() function of the ExampleContract contract.

To estimate the gas fee for sending a message from Abstract Sepolia to Arbitrum Sepolia, run the following command:

cast send <ABSTRACT_TESTNET_CONTRACT_ADDRESS> --rpc-url $ABSTRACT_TESTNET_RPC "estimateFee(uint32, string, bytes)" $ARBITRUM_TESTNET_LZ_ENDPOINT_ID "Hello World" 0x00030100110100000000000000000000000000030d40 --account deployer
Enter fullscreen mode Exit fullscreen mode

Info: Replace <ABSTRACT_TESTNET_CONTRACT_ADDRESS> with the contract address of your deployed ExampleContract contract on Abstract Sepolia before running the provided cast command.

The command above calls estimateFee(uint32, string, bytes, bool), while providing the required arguments, including: the endpoint ID of the destination chain, the text to send, and the message options (generated in the last section).

Sending the message

Once you have fetched the estimated gas for sending your message, you can now call sendMessage and provide the value returned as the msg.value.

For example, to send a message from Abstract Sepolia to Arbitrum Sepolia with an estimated gas fee, run the following command:

cast send <ABSTRACT_TESTNET_CONTRACT_ADDRESS> --rpc-url $ABSTRACT_TESTNET_RPC --value <GAS_ESTIMATE_IN_WEI> "sendMessage(uint32, string, bytes)" $ARBITRUM_TESTNET_LZ_ENDPOINT_ID "Hello World" 0x00030100110100000000000000000000000000030d40 --account deployer
Enter fullscreen mode Exit fullscreen mode

Info: Replace <ABSTRACT_TESTNET_CONTRACT_ADDRESS> with the contract address of your deployed ExampleContract contract on Abstract Sepolia, and <GAS_ESTIMATE_IN_WEI> with the gas estimate (in wei) returned by the call to estimateFee, before running the provided cast command.

You can view the status of your cross-chain transaction on LayerZero Scan.

Receiving the message

Once the message has been sent and received on the destination chain, the _Receive function will be called on the ExampleContract and the message payload will be stored in the contract's public data variable.

You can use the cast command to read the latest message received by the ExampleContract stored in the data variable.

To read the latest received message data that was sent to Arbitrum Sepolia from Abstract Sepolia, run the following command:

cast send <ARBITRUM_TESTNET_CONTRACT_ADDRESS> --rpc-url $ARBITRUM_TESTNET_RPC "data" --account deployer
Enter fullscreen mode Exit fullscreen mode

The returned data should match the message text payload you sent in your message.

You can view the status of your cross-chain transaction on LayerZero Scan.


Conclusion

Congratulations! You have successfully learned how to perform cross-chain messaging between Abstract and other chains (i.e. Arbitrum) using LayerZero V2.

To learn more about cross-chain messaging and LayerZero V2, check out the following resources:

Top comments (0)