DEV Community

Cover image for <>Building a Cross-Chain Swap with LayerZero on Monad </>
Neeraj Choubisa
Neeraj Choubisa

Posted on

<>Building a Cross-Chain Swap with LayerZero on Monad </>

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

How Does Layer Zero Works ?

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.

Creating a New Hardhat Project

npx hardhat 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 Hardhat project, we run the following:

npm install LayerZero-Labs/solidity-examples

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

const config: HardhatUserConfig = {
solidity: "0.8.18",
};

Inside the contract directory, delete Lock.sol and create two files new files instead.

- Monad_Swap.sol
- Sepolia_Swap.sol
Enter fullscreen mode Exit fullscreen mode

The first will become the endpoint on the Monad 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 Monad_Swap is NonblockingLzApp {
  // the implementation will go here
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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 Monad endpoint on Monad Explorer.

Implementing the Constructor

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

    // If Source == Sepolia, then Destination Chain = Monad
    if (_lzEndpoint == 0x6EDCE65403992e310A62460808c4b910D972f10f)
    destChainId = 40204;

    // If Source == Monad, then Destination Chain = Sepolia
    if (_lzEndpoint == 0x6C7Ab2202C98C4227C5c46f1417D81144DA716Ff)
    destChainId = 40161;
  }


Enter fullscreen mode Exit fullscreen mode

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 Monad, 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 Monad");
    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(""));
}

Enter fullscreen mode Exit fullscreen mode

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);
}

Enter fullscreen mode Exit fullscreen mode

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. Monad 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);
    }
}
Enter fullscreen mode Exit fullscreen mode

The Complete Contract for Monad
After adding a few comments, this is what the finished Monad_Swap.sol should look like:

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.19;

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

/**
 * @title Monad_Swap
 * @dev This contract sends a cross-chain message from Mumbai to Sepolia to transfer ETH in return for deposited Monad.
 */
contract Monad_Swap 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 = Monad
        if (_lzEndpoint == 0x6EDCE65403992e310A62460808c4b910D972f10f)
            destChainId = 40204;

        // If Source == Monad, then Destination Chain = Sepolia
        if (_lzEndpoint == 0x6C7Ab2202C98C4227C5c46f1417D81144DA716Ff)
            destChainId = 40161;
    }

    /**
     * @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 Monad");
    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);
    }
}

Enter fullscreen mode Exit fullscreen mode

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

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

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.19;

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

/**
 * @title Sepolia_Swap
 * @dev This contract sends a cross-chain message from Sepolia to Monad to transfer Monad in return for deposited ETH.
 */
contract Sepolia_Swap 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 = Monad
        if (_lzEndpoint == 0x6EDCE65403992e310A62460808c4b910D972f10f)
            destChainId = 40204;

        // If Source == Monad, then Destination Chain = Sepolia
        if (_lzEndpoint == 0x6C7Ab2202C98C4227C5c46f1417D81144DA716Ff)
            destChainId = 40161;
    }

    /**
     * @dev Allows users to swap to Monad.
     * @param Receiver Address of the receiver.
     */
    function swapTo_Monad(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);
    }
}

Enter fullscreen mode Exit fullscreen mode

Compile the Contracts
npx hardhat compile

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:

BASE_SEPOLIA_RPC_URL=
MONAD_RPC_URL=
DEPLOYER_ACCOUNT_PRIV_KEY=

Enter fullscreen mode Exit fullscreen mode

Let's Go For Scripts

import { ethers } from "hardhat";

async function deployMonadSwap() {
  const CONTRACT_NAME = "Monad_Swap";
  const lzAddressMonad = "0x6C7Ab2202C98C4227C5c46f1417D81144DA716Ff";
  const monadSwap = await ethers.deployContract(CONTRACT_NAME, [
    lzAddressMonad,
  ]);
  await monadSwap.waitForDeployment();
  console.log(
    "Deployed Monad Swap Contract Address:",
    await monadSwap.getAddress()
  );
}

async function main() {
  await deployMonadSwap();
}

main().catch((error) => {
  console.error(error);
  process.exit(1);
});

Enter fullscreen mode Exit fullscreen mode
Run : 
nikku.jr.dev@Neerajs-MacBook-Air swap-layerzero % npx hardhat run scripts/deploy_monad_swap.ts --network monad
Deployed Monad Swap Contract Address: 0xf9Ccd4509d3049ceFe62F800a44b3d66943D0308

Enter fullscreen mode Exit fullscreen mode

Similar for Base Sepolia :

nikku.jr.dev@Neerajs-MacBook-Air swap-layerzero % npx hardhat run scripts/deploy_sepolia_swap.ts --network baseSepolia 

Deployed Sepolia Swap Contract Address: 0xd0eBE4D0E7C6a7786942c3dcD1390a0C8EE84040

Enter fullscreen mode Exit fullscreen mode

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 Base Sepolia, we call the function with the following params:

_remoteChainId = 40204 (LayerZero Chain ID for Monad)
_remoteAddress = Address of our Monad Swap contract


On Monad, call the function with the following params:

_remoteChainId = 40245 (LayerZero Chain ID for Base Sepolia)
_remoteAddress = Address of the Base Sepolia contract


Lastly, we ensure to send MON and ETH to each contract. Remember, the contracts are the ones paying for gas, so we have to send 30 each to both of the contracts. (I know very hectic because of faucets not available right now ) πŸ˜‚

Enter fullscreen mode Exit fullscreen mode

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

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!

Image description

Twitter
Linkedin

Billboard image

The Next Generation Developer Platform

Coherence is the first Platform-as-a-Service you can control. Unlike "black-box" platforms that are opinionated about the infra you can deploy, Coherence is powered by CNC, the open-source IaC framework, which offers limitless customization.

Learn more

Top comments (0)

Sentry image

See why 4M developers consider Sentry, β€œnot bad.”

Fixing code doesn’t have to be the worst part of your day. Learn how Sentry can help.

Learn more

πŸ‘‹ Kindness is contagious

If you found this post helpful, please consider leaving a ❀️ or a kind comment!

Sounds good!