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.
- Download Metamask
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
Then run:
cd myproject
forge init
This will create a Foundry project with the following basic layout:
.
├── foundry.toml
├── script
├── src
└── test
Info: You can delete the
src/Counter.sol
,test/Counter.t.sol
, andscript/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
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',
]
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;
}
}
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) {}
}
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 Endpointaddress
for the chain the smart contract is deployed to. -
_owner
: Theaddress
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
);
}
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);
}
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 callingsendMessage
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
}
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) {}
}
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));
}
}
Compiling the smart contract
Compile the smart contract to ensure it builds without any errors.
To compile your smart contract, run:
forge build
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
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
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
Once the .env
file has been created, run the following command to load the environment variables in the current command line session:
source .env
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
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
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
Info: Replace
<ABSTRACT_TESTNET_CONTRACT_ADDRESS>
with the contract address of your deployedExampleContract
contract on Abstract Sepolia, and<ARBITRUM_TESTNET_CONTRACT_ADDRESS>
with the contract address (as bytes) of your deployedExampleContract
contract on Arbitrum Sepolia before running the providedcast
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
Info: Replace
<ARBITRUM_TESTNET_CONTRACT_ADDRESS>
with the contract address of your deployedExampleContract
contract on Arbitrum Sepolia, and<ABSTRACT_TESTNET_CONTRACT_ADDRESS>
with the contract address (as bytes) of your deployedExampleContract
contract on Abstract Sepolia before running the providedcast
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:
- Build message options to specify logic associated with the message transaction
- Call the
estimateFee
function to estimate the gas fee for sending a message - 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);
}
}
The output of this script results in:
0x00030100110100000000000000000000000000030d40
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
Info: Replace
<ABSTRACT_TESTNET_CONTRACT_ADDRESS>
with the contract address of your deployedExampleContract
contract on Abstract Sepolia before running the providedcast
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
Info: Replace
<ABSTRACT_TESTNET_CONTRACT_ADDRESS>
with the contract address of your deployedExampleContract
contract on Abstract Sepolia, and<GAS_ESTIMATE_IN_WEI>
with the gas estimate (in wei) returned by the call to estimateFee, before running the providedcast
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
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)