Building a Cross-Chain Swap with LayerZero

Building a Cross-Chain Swap with LayerZero

We will build a DApp with LayerZero that allows users to bridge and swap MATIC tokens from Mumbai testnet to ETH tokens on Sepolia testnet.

Cross-chain communication is one of the hottest topics in Web3 today. There are many blockchains in Web3, meaning divided liquidity pools and isolated smart contracts that cannot talk across their blockchain environments. Engineering teams worldwide are trying to deliver a product that allows seamless interchain communication to solve this.

LayerZero is one of these solutions, with an already-functioning product that powers cross-chain bridging on popular decentralized exchanges like Sushiswap and PancakeSwap.

This article will examine LayerZero and how it helps send cross-chain messages.

How does LayerZero Work?

Any cross-chain communication protocol requires an off-chain entity to send messages to and from. However, this creates a centralization risk.

Here's how LayerZero organizes its' infrastructure:

  • LayerZero deployed a smart contract as an endpoint on each supported blockchain. This endpoint serves as a point of assembly for all cross-chain messages. Endpoints are to LayerZero what airports are to air travel.
  • An off-chain relayer listens to these endpoints and picks up messages as they arrive. LayerZero lets us run our own relayer, lowering the risk of centralization.
  • However, LayerZero only partially relies on a relayer to deliver these messages. In practice, a relayer works with an oracle that confirms or denies the validity of a transaction.
  • The messages are delivered to their destination only if both independent entities agree on the validity of a transaction.
  • A relayer is usually paired with decentralized oracle networks like Chainlink to ensure reliability, although, in theory, we can also develop our own..

Note: All of the technical information in this article about LayerZero's working comes from their whitepaper.

Target Audience

This guide assumes an understanding of Foundry. If you are uncomfortable with it, check out the Foundry book.

Prerequisites

This guide requires an up-to-date installation of Foundry.

Building a Cross-Chain Swap Between Sepolia and Mumbai Testnets

We will build a cross-chain swap between the Sepolia testnet and the Mumbai testnet that will allow users to swap ETH for MATIC and vice-versa at the click of a button.

D_D Newsletter CTA

Creating a New Foundry Project

We create a new directory and open a terminal inside it to get started. To initialize a new Foundry project, we run the following command:

forge init

This LayerZero example repository contains all the interfaces and abstract contracts we need to talk to the on-chain LayerZero endpoints. We also need the Openzeppelin-contracts library to work with access control in our smart contracts.

To download these repos into our Foundry project, we run the following:

forge install LayerZero-Labs/solidity-examples OpenZeppelin/openzeppelin-contracts

We will work with version 0.8.19 of the Solidity compiler. To ensure Forge uses this exact version to compile all of the Solidity code, we add this line to the foundry.toml file in our project directory:

solc_version = '0.8.19'
# The TOML file can be used to configure almost all aspects of
# a Foundry project

Lastly, since we will be importing Solidity code from different files, we must tell Forge about our remappings. Add this array to the foundry.toml file:

remappings = [
    "@layerzero-contracts/=lib/solidity-examples/contracts/",
    "@openzeppelin/=lib/openzeppelin-contracts/"
]

Implementing the Smart Contracts

With our dev environment set up, we are finally ready to begin.

Disclaimer: This project will be an extremely simplified version of what a cross-chain swap built on top of LayerZero could look like; it is for educational purposes only and should not be considered for any project in production.

If you want to dive deeper, I recommend you check out Stargate Finance.

Inside the src directory, delete Counter.sol and create two files new files instead.

  1. LayerZeroSwap_Mumbai.sol
  2. LayerZeroSwap_Sepolia.sol

The first will become the endpoint on the Mumbai testnet and the other on Sepolia.

Creating the Base Contract

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.19;

import "@layerzero-contracts/lzApp/NonblockingLzApp.sol";

contract LayerZeroSwap_Mumbai is NonblockingLzApp {
  // the implementation will go here
}

NonblockingLzApp is an abstract contract built on underlying LayerZero contracts. The purpose of this contract is to make it easier for devs to interact with the on-chain contracts.

Adding the Variables

    // State variables for the contract    
    uint16 public destChainId;
    bytes payload;
    address payable deployer;
    address payable contractAddress = payable(address(this));

    // Instance of the LayerZero endpoint
    ILayerZeroEndpoint public immutable endpoint;

The destChainId variable represents the destination chain's address, not the chain we deploy this contract to. While sending a cross-chain message using LayerZero, we need to specify the address of the intended destination.

Note: These aren't the Chain IDs you might know. To quote the LayerZero docs: "chainId values are not related to EVM IDs. Since LayerZero will span EVM & non-EVM chains the chainId are proprietary to our Endpoints." Meaning, LayerZero maintains its own set of Chain IDs to identify blockchains that differ from the normally used numbers. We can find the full reference in their docs.

Payload holds the message we send as bytes. This variable is an ABI-encoded amalgamation of everything we want to send across the chain.

We initialize the deployer variable in the constructor to the contract owner.

The contractAddress represents the contract address we know after deployment. We also initialize it in the constructor.

The endpoint variable is an instance of ILayerZeroEndpoint interface; we use it to interact with the on-chain endpoints. For an example, check out their Mumbai endpoint on Polygonscan.

Implementing the Constructor

  constructor(address _lzEndpoint) NonblockingLzApp(_lzEndpoint) {
    deployer = payable(msg.sender);
    endpoint = ILayerZeroEndpoint(_lzEndpoint);

    // If Source == Sepolia, then Destination Chain = Mumbai
    if (_lzEndpoint == 0xae92d5aD7583AD66E49A0c67BAd18F6ba52dDDc1)
    destChainId = 10109;

    // If Source == Mumbai, then Destination Chain = Sepolia
    if (_lzEndpoint == 0xf69186dfBa60DdB133E91E9A4B5673624293d8F8)
    destChainId = 10161;
  }

We must pass the address of the on-chain endpoint to the NonblockingLzApp contract for a successful initialization.

In this example, we have written two if-statements that automatically assign the Chain ID based on the address of the endpoint contract. If we deployed the contract on Mumbai, the destChainId variable will point to Sepolia, and vice-versa.

Interlude: How do Smart Contracts Interact with the LayerZero Protocol?

We are now ready to write the two main functions we need to use the LayerZero protocol. But before that, let us take a conceptual detour.

For any smart contract, interacting with the on-chain LayerZero endpoint is a two-part process:

First, we call the send() function on the endpoint to send a message. To be clear, this is a function we call on an already deployed contract, not something we define.

Second, we define the _nonblockingLzReceive function in your contract. Any contract that wants to receive cross-chain messages must have this function defined in their contract. The LayerZero endpoint calls this function on our contract to deliver an incoming message. To be clear: We do not call this function; we just define it!

Implementing the swapTo_ETH Function

Let us now define the main swap function. We create a new function named swapTo_ETH and define it as follows:

function swapTo_ETH(address Receiver) public payable {
    require(msg.value >= 1 ether, "Please send at least 1 MATIC");
    uint value = msg.value;

    bytes memory trustedRemote = trustedRemoteLookup[destChainId];
    require(trustedRemote.length != 0, "LzApp: destination chain is not a trusted source");
    _checkPayloadSize(destChainId, payload.length);

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

    endpoint.send{value: 15 ether}(destChainId, trustedRemote, payload, contractAddress, address(0x0), bytes(""));
}

First, we ensure the user has sent at least 1 ETH in value while calling the function.

A function named setTrustedRemoteAddress inside the contract we inherit allows us to designate trusted contracts. This way, we can ensure our contract only interacts with trusted code. trustedRemoteLookup[destChainId] returns the endpoint address from the other chain we trust. The _checkPayloadSize function ensures our payload size is within acceptable limits.

Next, we pack the data we want to send into a single variable of type bytes using abi.encode(). In our case, we ask the user to tell us the destination address on the other side.

Finally, we call the send() and transfer 15 ETH from our own smart contract to pay for the gas.

Note: I can already hear you shouting: "15 ETH! What the hell is going on here?"

We need to pay some gas fees to the endpoint for the execution of our transaction. The actual fee isn't 15 ETH. However, in my experience, transactions that were accompanied by less gas didn't execute.

While we set 15 ETH in this swap implementation, we get back all unused ETH. 15 ETH is just a buffer to ensure the transaction goes through.

LayerZero does have functions like estimateGasFees() that allow us to estimate the amount of gas that needs to be sent, but I found it to be inaccurate.

Implementing the _nonblockingLzReceive Function

Well, this is how we send a message to the Sepolia testnet, but what if we receive a message from there?

To make sure our contract is ready to handle incoming messages, we need to implement a function named _nonblockingLzReceive:

function _nonblockingLzReceive(uint16 _srcChainId, bytes memory _srcAddress, uint64 _nonce, bytes memory _payload) internal override {

    (address Receiver , uint Value) = abi.decode(_payload, (address, uint));
    address payable recipient = payable(Receiver);        
    recipient.transfer(Value);
}

This is the function that LayerZero calls upon our contract to deliver a message. We know what we encoded on the other end. So, we can decode it into a recipient's address and an integer value representing the amount in Wei that we locked into the contract on the other end.

Next, we transfer that value to the recipient by calling the transfer() function. MATIC and ETH are two very different assets with wildly different values. In any practical implementation of a cross-chain swap, we would use an oracle to coordinate real-time price mediation between the two assets. For this example, we will assume an exchange rate of 1:1.

Lastly, we will wrap up the contract code with two simple functions:

    // Fallback function to receive ether
    receive() external payable {}

    /**
     * @dev Allows the owner to withdraw all funds from the contract.
     */
    function withdrawAll() external onlyOwner {
        deployer.transfer(address(this).balance);
    }
}

The Complete Contract for Mumbai

After adding a few comments, this is what the finished LayerZeroSwap_Mumbai.sol should look like:

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.19;

import "@layerzero-contracts/lzApp/NonblockingLzApp.sol";

/**
 * @title LayerZeroSwap_Mumbai
 * @dev This contract sends a cross-chain message from Mumbai to Sepolia to transfer ETH in return for deposited MATIC.
 */
contract LayerZeroSwap_Mumbai is NonblockingLzApp {

    // State variables for the contract    
    uint16 public destChainId;
    bytes payload;
    address payable deployer;
    address payable contractAddress = payable(address(this));

    // Instance of the LayerZero endpoint
    ILayerZeroEndpoint public immutable endpoint;

    /**
     * @dev Constructor that initializes the contract with the LayerZero endpoint.
     * @param _lzEndpoint Address of the LayerZero endpoint.
     */
    constructor(address _lzEndpoint) NonblockingLzApp(_lzEndpoint) {
        deployer = payable(msg.sender);
        endpoint = ILayerZeroEndpoint(_lzEndpoint);

        // If Source == Sepolia, then Destination Chain = Mumbai
        if (_lzEndpoint == 0xae92d5aD7583AD66E49A0c67BAd18F6ba52dDDc1) destChainId = 10109;

        // If Source == Mumbai, then Destination Chain = Sepolia
        if (_lzEndpoint == 0xf69186dfBa60DdB133E91E9A4B5673624293d8F8) destChainId = 10161;
    }

    /**
     * @dev Allows users to swap to ETH.
     * @param Receiver Address of the receiver.
     */
    function swapTo_ETH(address Receiver) public payable {
        require(msg.value >= 1 ether, "Please send at least 1 MATIC");
        uint value = msg.value;

        bytes memory trustedRemote = trustedRemoteLookup[destChainId];
        require(trustedRemote.length != 0, "LzApp: destination chain is not a trusted source");
        _checkPayloadSize(destChainId, payload.length);

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

        endpoint.send{value: 15 ether}(destChainId, trustedRemote, payload, contractAddress, address(0x0), bytes(""));
    }

    /**
     * @dev Internal function to handle incoming LayerZero messages.
     */
    function _nonblockingLzReceive(uint16 _srcChainId, bytes memory _srcAddress, uint64 _nonce, bytes memory _payload) internal override {

        (address Receiver , uint Value) = abi.decode(_payload, (address, uint));
        address payable recipient = payable(Receiver);        
        recipient.transfer(Value);
    }

    // Fallback function to receive ether
    receive() external payable {}

    /**
     * @dev Allows the owner to withdraw all funds from the contract.
     */
    function withdrawAll() external onlyOwner {
        deployer.transfer(address(this).balance);
    }
}

The Complete Sepoplia Contract

The Sepolia counterpart of this contract is almost the same, just the other way around.

Now, inside LayerZeroSwap_Sepolia.sol, paste the following code:

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.19;

import "@layerzero-contracts/lzApp/NonblockingLzApp.sol";

/**
 * @title LayerZeroSwap_Sepolia
 * @dev This contract sends a cross-chain message from Sepolia to Mumbai to transfer MATIC in return for deposited ETH.
 */
contract LayerZeroSwap_Sepolia is NonblockingLzApp {

    // State variables for the contract
    address payable deployer;    
    uint16 public destChainId;
    bytes payload;    
    address payable contractAddress = payable(address(this));

    // Instance of the LayerZero endpoint
    ILayerZeroEndpoint public immutable endpoint;

    /**
     * @dev Constructor that initializes the contract with the LayerZero endpoint.
     * @param _lzEndpoint Address of the LayerZero endpoint.
     */
    constructor(address _lzEndpoint) NonblockingLzApp(_lzEndpoint) {
        deployer = payable(msg.sender);
        endpoint = ILayerZeroEndpoint(_lzEndpoint);

        // If Source == Sepolia, then Destination Chain = Mumbai
        if (_lzEndpoint == 0xae92d5aD7583AD66E49A0c67BAd18F6ba52dDDc1) destChainId = 10109;

        // If Source == Mumbai, then Destination Chain = Sepolia
        if (_lzEndpoint == 0xf69186dfBa60DdB133E91E9A4B5673624293d8F8) destChainId = 10161;
    }

    /**
     * @dev Allows users to swap to MATIC.
     * @param Receiver Address of the receiver.
     */
    function swapTo_MATIC(address Receiver) public payable {
        require(msg.value >= 1 ether, "Please send at least 1 ETH");
        uint value = msg.value;

        bytes memory trustedRemote = trustedRemoteLookup[destChainId];
        require(trustedRemote.length != 0, "LzApp: destination chain is not a trusted source");
        _checkPayloadSize(destChainId, payload.length);

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

        endpoint.send{value: 15 ether}(destChainId, trustedRemote, payload, contractAddress, address(0x0), bytes(""));
    }

    /**
     * @dev Internal function to handle incoming LayerZero messages.
     */
    function _nonblockingLzReceive(uint16 _srcChainId, bytes memory _srcAddress, uint64 _nonce, bytes memory _payload) internal override {
        (address Receiver , uint Value) = abi.decode(_payload, (address, uint));
        address payable recipient = payable(Receiver);        
        recipient.transfer(Value);
    }

    // Fallback function to receive ether
    receive() external payable {}

    /**
     * @dev Allows the owner to withdraw all funds from the contract.
     */
    function withdrawAll() external onlyOwner {
        deployer.transfer(address(this).balance);
    }
}

D_D Newsletter CTA

Building the Contracts

We delete the default files inside the script and test directory and run:

forge build

This command might print some warnings, but we can ignore them.

Creating the .env File

We need to add our sensitive information in a .env file to store it securely. So, we create one at the root of our project and fill it with this:

SEPOLIA_RPC_URL=
MUMBAI_RPC_URL=

PRIVATE_KEY=

ETHERSCAN_API_KEY=
POLYGONSCAN_API_KEY=

We can get RPC URLs from services like Alchemy and Chainstack, or you could use a public RPC URL. We need 2 RPC URLs since we intend to deploy on two networks.

We use a private key corresponding to a wallet that holds both ETH and MATIC. Get API keys from Etherscan and Polygonscan. Keys from the mainnet explorers will work on Sepolia and Mumbai as well.

With all the values filled, save the .env file. Run this command in the terminal to source these variables to the terminal:

source .env

Implementing the Deployment Scripts

We will deploy our contracts by writing Solidity scripts.
Create two files inside the script directory, each deploying one contract.

  • Deploy_Sepolia.s.sol
  • Deploy_Mumbai.s.sol

Inside Deploy_Sepolia.s.sol, add the following code:

pragma solidity ^0.8.13;

import "forge-std/Script.sol";
import {LayerZeroSwap_Sepolia} from "../src/LayerZeroSwap_Sepolia.sol";

contract MyScript is Script {
  LayerZeroSwap_Sepolia layerZeroSwap_Sepolia;

  function run() external {        

    uint256 PrivateKey = vm.envUint("PRIVATE_KEY");
    vm.startBroadcast(PrivateKey);

    layerZeroSwap_Sepolia = new LayerZeroSwap_Sepolia(0xae92d5aD7583AD66E49A0c67BAd18F6ba52dDDc1);

    vm.stopBroadcast();
  }
}

To write a deployment script using Foundry, we need to write a contract that inherits from script.sol.

First, we declare a variable for the layerZeroSwap_Sepolia contract. Then we initialize layerZeroSwap_Sepolia, which represents an on-chain deployment. Anything we write between the startBroadcast() and stopBroadcast()will be executed as an on-chain transaction.

We pass the address of the Sepolia endpoint as a constructor argument.

Similarly, we paste the following code inside Deploy_Mumbai.s.sol for the Mumbai deploy script:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import "forge-std/Script.sol";
import {LayerZeroSwap_Mumbai} from "../src/LayerZeroSwap_Mumbai.sol";

contract MyScript is Script {
    LayerZeroSwap_Mumbai layerZeroSwap_Mumbai;

    function run() external {        

        uint256 PrivateKey = vm.envUint("PRIVATE_KEY");
        vm.startBroadcast(PrivateKey);

        layerZeroSwap_Mumbai = new LayerZeroSwap_Mumbai(0xf69186dfBa60DdB133E91E9A4B5673624293d8F8);

        vm.stopBroadcast();
    }
}

Deploying and Verifying the Contracts

To execute Deploy_Mumbai.s.sol, we run this command in your terminal:

forge script script/Deploy_Mumbai.s.sol:DeployMumbai \
--rpc-url $MUMBAI_RPC_URL \
--broadcast -vvvv

Note: We set the verbosity flag (-v) to the maximum of 4. This gives us an entire stack trace of any script we execute in the terminal, which can be very helpful for debugging.

After deploying a contract, Foundry always returns an address. We can use it to verify this contract with the following command:

forge verify-contract 0x5Bf61Ac6a7B63dDB5D80D125Bc7054916A50d99E \
--chain-id 80001 \
--num-of-optimizations 200 \
--watch --compiler-version v0.8.19+commit.7dd6d404 \
--constructor-args $(cast abi-encode "constructor(address)" 0xf69186dfBa60DdB133E91E9A4B5673624293d8F8)
src/LayerZeroSwap_Mumbai.sol:LayerZeroSwap_Mumbai \
--etherscan-api-key $POLYGONSCAN_API_KEY

This is what your terminal should look like right now:

Similarly, we can execute the Deploy_Sepolia.s.sol with this command:

forge script script/Deploy_Sepolia.s.sol:DeploySepolia \
--rpc-url $SEPOLIA_RPC_URL \
--broadcast -vvvv

Finally, we can verify the deployments with this command:

forge verify-contract 0xDA96bbe0B02e64D374bD98355a91405653b081E2 \
--chain-id 11155111 \
--num-of-optimizations 200 \
--watch --compiler-version v0.8.19+commit.7dd6d404 \
--constructor-args $(cast abi-encode "constructor(address)" 0xae92d5aD7583AD66E49A0c67BAd18F6ba52dDDc1) \
src/LayerZeroSwap_Sepolia.sol:LayerZeroSwap_Sepolia \
--etherscan-api-key $ETHERSCAN_API_KEY

Connecting the Two Contracts

Remember how we can use the setTrustedRemoteAddress() to designate trusted contracts? We need to tell each endpoint of its counterparts so they can call each other.

On Sepolia, we call the function with the following params:

  • _remoteChainId = 10109 (LayerZero Chain ID for Mumbai)
  • _remoteAddress = Address of our Mumbai contract

On Mumbai, call the function with the following params:

  • _remoteChainId = 10161 (LayerZero Chain ID for Sepolia)
  • _remoteAddress = Address of the Sepolia contract

Lastly, we ensure to send ETH and MATIC to each contract. Remember, the contracts are the ones paying for gas, so we have to send 30 each to both of the contracts.

Issues with this Implementation

Before wrapping things up, let's discuss some issues in our cross-chain swap:

  • ETH and MATIC are not equivalent assets. Any production-ready implementation needs one or more reliable data feeds for the exchange rate.

  • There is no error handling for failed transactions. If LayerZero fails to deliver a message, we need checks that take over in that scenario.

  • We did not charge users any fees. In a production implementation, the cross-chain swap must charge some fees to cover their costs.

All things considered, this is a very basic implementation, but the Stargate Finance's Github repo offers a production-ready solution as a good example.

D_D Newsletter CTA

Conclusion

With the robust capabilities of LayerZero, our solution stands ready to facilitate a seamless 1:1 swap between ETH and MATIC.

Should any queries or thoughts arise, don't hesitate to get in touch with me on Twitter. I'm always eager to engage and assist.

Here's to the boundless possibilities of cross-chain innovations!