LayerZero V2 + Morph: Seamless Cross-Chain Token Transfers

LayerZero V2 + Morph: Seamless Cross-Chain Token Transfers

Layerzero & the need for seamless interoperability

As blockchain ecosystems continue to expand, the need for cross-chain functionality has become increasingly critical. Assets and data often exist in isolation on different blockchains, limiting users and developers who wish to interact with multiple ecosystems simultaneously. Cross-chain functionality addresses this by allowing assets, tokens, and information to move freely between chains, enhancing user experience and opening up,  new possibilities for decentralized applications (dApps).

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.

Bridging ERC20 token to Morph using Layerzero v2

In this tutorial, we will walk through deploying and configuring an OFT(Omnichain Fungible Token).

Omnichain Fungible Tokens (OFTs) are a new token standard pioneered by LayerZero for cross-chain assets. OFTs allow fungible tokens to be transferred across different blockchains without the need for asset wrapping or liquidity pools, all whilst maintaining a unified supply.

In this guide, You'll learn how to configure your environment for cross-chain deployments, deploy your OFT contract, set peers and transfer tokens from sepolia to Morph holesky .

Prerequisites

  • Pnpm
  • A wallet configured with Sepolia and morph holesky.
  • Testnet funds: 
    • Sepolia Testnet ETH: For deploying contracts and paying gas fees - claim from faucet
    • Morph Testnet ETH - For deploying contracts and paying gas fees-  claim from faucet

Setting up your environment

In your terminal, create a new folder and paste in the command below. We will be using the layerzero CLI to provision our dApp.

npx create-lz-oapp@latest

After running the command above , you should get options on how to configure your dapp. For this guide, we will be using the OFT example. 

So for the first question, which is where to start the project, hit “Enter” to select the fault option.

Next, we select OFT as our starting point, then we select pnpm to install our dependencies.

hardhat.config.ts

After opening our project on vscode, our first stop is the hardhat config. This is where we will be configuring the networks for our dApp. In the networks section of the config file, replace the default networks with the ones below.

  'sepolia-testnet': {
            eid: EndpointId.SEPOLIA_V2_TESTNET,
            url: 'https://rpc.sepolia.org/',
            accounts,
        },
        'morph-holesky': {
            eid: EndpointId.MORPH_V2_TESTNET,
            url: process.env.RPC_URL_MORPH,
            accounts,
        },

Your hardhat config file should look like this.

For this tutorial, we will be transferring tokens (which we are going to create) from Sepolia to Morph. Eid  refers to the endpoint ID of the network. Each chain on layer zero has an endpoint address(we will use this later) and an endpoint ID. url is the respective URLs of the networks.

.env

The next step is provisioning our environment variables. In the root folder of your project, rename the .env.example file to .env. Next, we add and populate a number of variables.

RPC_URL_MORPH=https://rpc-quicknode-holesky.morphl2.io
PRIVATE_KEY=your-private-key

MyOFT.sol

Now, its time to work on the contract itself. We will be making a slight modification to the existing contract. We will be adding the line below to the constructor function to mint on deployment, 1000000 tokens to the deployer.

_mint(msg.sender, 100_000 * 10 ** 18);

Next, we compile the contracts to ensure all works as expected.

pnpm compile:hardhat

Deploying the OFT contract

To deploy our OFT contract, we must first initialise our layerzero.config.ts file. Delete the layerzero.config.ts file from your root folder and run the following command in your terminal

npx hardhat lz:oapp:config:init --contract-name MyOFT --oapp-config my_oft_config.ts

The above command should return two options to you which are the networks we added to the hardhat.config.ts file. Use the spacebar key to select both chains and then hit Enter.

Ensure the my_oft_config.ts file has been created and populated. It should be exactly like the one below.

Finally, to deploy the contract, run and again, select both networks and hit Enter. For the deploy script tag option, hit enter to use the default option(all the scripts).

npx hardhat lz:deploy

Running the deploy command should deploy our OFT contract to both networks. You could check out the deployed contracts on Sepolia and Morph holesky explorer.

Configuring cross chain pathways

Next step is configuring each pathway for our contract. You can read up more on configuring pathways here.  To modify your layerzero contract, run the command below

npx hardhat lz:oapp:wire --oapp-config my_oft_config.ts

For each pathway on your config file, this task will call:

fromContract.OApp.setPeer fromContract.OApp.setEnforcedOptions fromContract.EndpointV2.setSendLibrary fromContract.EndpointV2.setReceiveLibrary fromContract.EndpointV2.setReceiveLibraryTimeout fromContract.EndpointV2.setConfig(OApp, sendLibrary, sendConfig) fromContract.EndpointV2.setConfig(OApp, receiveLibrary, receiveConfig)

Running the command should also populate your my_oft_config.ts file by applying all the custom configurations needed . Your config file should now look like below.

Sending tokens from Sepolia to Morph

After configuring the path, we can finally start working on sending tokens across chains. First we create a folder called tasks in our root directory of our project. Inside the folder, we create a file called sendToken.ts. After creating the file, paste in the code below.

import { ethers } from 'ethers'
import { task } from 'hardhat/config'

import { createGetHreByEid, createProviderFactory, getEidForNetworkName } from '@layerzerolabs/devtools-evm-hardhat'
import { Options } from '@layerzerolabs/lz-v2-utilities'

task('lz:oft:send', 'Send tokens cross-chain using LayerZero technology')
    .addParam('contractA', 'Contract address on network A')
    .addParam('recipientB', 'Recipient address on network B')
    .addParam('networkA', 'Name of the network A')
    .addParam('networkB', 'Name of the network B')
    .addParam('amount', 'Amount to transfer in token decimals')
    .addParam('privateKey', 'Private key of the sender')
    .setAction(async (taskArgs, hre) => {
        const eidA = getEidForNetworkName(taskArgs.networkA)
        const eidB = getEidForNetworkName(taskArgs.networkB)
        const contractA = taskArgs.contractA
        const recipientB = taskArgs.recipientB

        const environmentFactory = createGetHreByEid()
        const providerFactory = createProviderFactory(environmentFactory)
        const provider = await providerFactory(eidA)
        const wallet = new ethers.Wallet(taskArgs.privateKey, provider)

        const oftContractFactory = await hre.ethers.getContractFactory('MyOFT', wallet)
        const oft = oftContractFactory.attach(contractA)

        const decimals = await oft.decimals()
        const amount = hre.ethers.utils.parseUnits(taskArgs.amount, decimals)
        const options = Options.newOptions().addExecutorLzReceiveOption(200000, 0).toHex().toString()
        const recipientAddressBytes32 = hre.ethers.utils.hexZeroPad(recipientB, 32)

        // Estimate the fee
        try {
            console.log("Attempting to call quoteSend with parameters:", {
                dstEid: eidB,
                to: recipientAddressBytes32,
                amountLD: amount,
                minAmountLD: amount.mul(98).div(100),
                extraOptions: options,
                composeMsg: '0x',
                oftCmd: '0x',
            });
            const nativeFee = (await oft.quoteSend(
                [eidB, recipientAddressBytes32, amount, amount.mul(98).div(100), options, '0x', '0x'],
                false
            ))[0]
            console.log('Estimated native fee:', nativeFee.toString())

            // Overkill native fee to ensure sufficient gas
            const overkillNativeFee = nativeFee.mul(2)

            // Fetch the current gas price and nonce
            const gasPrice = await provider.getGasPrice()
            const nonce = await provider.getTransactionCount(wallet.address)

            // Prepare send parameters
            const sendParam = [eidB, recipientAddressBytes32, amount, amount.mul(98).div(100), options, '0x', '0x']
            const feeParam = [overkillNativeFee, 0]

            // Sending the tokens with increased gas price
            console.log(`Sending ${taskArgs.amount} token(s) from network ${taskArgs.networkA} to network ${taskArgs.networkB}`)
            const tx = await oft.send(sendParam, feeParam, wallet.address, {
                value: overkillNativeFee,
                gasPrice: gasPrice.mul(2),
                nonce,
                gasLimit: hre.ethers.utils.hexlify(7000000),
            })
            console.log('Transaction hash:', tx.hash)
            await tx.wait()
            console.log(
                `Tokens sent successfully to the recipient on the destination chain. View on LayerZero Scan: https://layerzeroscan.com/tx/${tx.hash}`
            )
        } catch (error) {
            console.error('Error during quoteSend or send operation:', error)
            if (error?.data) {
                console.error("Reverted with data:", error.data)
            }
        }
    })

After pasting in the code above, you might get some warnings. Your next step(which also clears those warnings)  should be to navigate to hardhat.config.ts and add the import below. This imports the sendToken task to our hardhat config.

import './tasks/sendToken'

Finally, we run the command below to transfer the tokens

npx hardhat lz:oft:send --contract-a <Sepolia contract> --recipient-b <Morph contract> --network-a sepolia-testnet --network-b morph-holesky --amount 1000 --private-key your-private-key

Replace the placeholder texts with the contract address of your sepolia and holesky deployments. In my case, this is what it looks like

npx hardhat lz:oft:send --contract-a 0xEC33dC84aEC542694B490168250b62E53ce6DB17 --recipient-b 0x33BE458A94f35857027bE9E4ae3E41C8c94d9589 --network-a sepolia-testnet --network-b morph-holesky --amount 1000 --private-key my-private-key

You should get a successful response like below in your terminal

You can see the successful transaction on layerzero scan

If we check on the morph holesky explorer, we can also see the 1000 tokens we received from sepolia.

Conclusion

We have taken a step by step approach to building a crosschain dapp that sends tokens from one chain (Sepolia) to another (Morph). This approach creates the ERC20 token to be sent.

 If you want to send already existing tokens, rather than using OFT, you use OFTAdapter and pass in the contract address of the token. The Adapter is deployed on the source chain and the OFT contract(like we just used) is deployed on the destination chain. If you would like to see a guide on this, drop a comment or reach out on discord