Build a Full-Stack Interchain Application With Next.js, Solidity & Axelar

Build a Full-Stack Interchain Application With Next.js, Solidity & Axelar

Today's blockchain ecosystem consists of many independent networks, each with unique features. This fragmentation creates challenges for seamless communication and collaboration. To overcome these barriers, a vital element that has emerged is a programmable blockchain interoperability layer.

The programmable blockchain interoperability layer bridges different blockchain networks, facilitating the exchange of information and assets securely and efficiently. It enables smooth interaction by providing a standardized framework for cross-chain communication, allowing various blockchain networks to interoperate seamlessly.

In this tutorial, you will learn how to build a full-stack interchain decentralized application with Next.js, Solidity and Axelar General Message Passing (GMP) to send messages from one blockchain to another.

For a quick start, you can find the complete code for this tutorial on GitHub. And in this video, you can see the completed app that offers users the ability to:

  • Connect their wallet.

  • Enter their desired message for cross-chain interaction.

  • Send the message from Binance to Avalanche.

Getting started with Axelar General Message Passing

Axelar General Message Passing (GMP) empowers developers by enabling them to call any function on interconnected chains seamlessly.

With GMP, developers gain the ability to:

  1. Call a contract on chain A and interact with a contract on chain B.

  2. Execute cross-chain transactions by calling a contract on chain A and sending tokens to chain B.

Prerequisite

Before getting started, you need the following prerequisites:

  • Node.js and its package manager NPM, version 18*. Verify Node.js is installed by running the following terminal command: node -v && npm -v

  • A basic understanding of JavaScript, Solidity and React/Next.js

Project setup and installation

To start the project setup and installation quickly, clone this project on GitHub. Make sure you're on the starter branch using the following command:

git clone https://github.com/axelarnetwork/fullstack-interchain-dapp

Next, install the project locally after cloning it using the following command in your terminal.

Here's how you can install the project using npm:

cd fullstack-interchain-dapp && npm i && npm run dev

Next.js will start a hot-reloading development environment accessible by default at http://localhost:3000.

Build a Full-Stack Interchain Application With Next.js, Solidity & Axelar

Building a smart contract with Hardhat and Axelar GMP

In this section, you will build an interchain smart contract leveraging the Axelar GMP feature to send messages from one chain to another.

Navigate to the project's root folder you cloned in the previous step, and then run the following commands to create a new Hardhat project.

mkdir hardhat && cd hardhat && npm install --save-dev hardhat

Get a sample project by running the command below:

npx hardhat

You'll go with the following options:

What do you want to do?
✔ Create A JavaScript Project
✔ Hardhat project root:
? Do you want to add a .gitignore? (Y/n) › y
? Do you want to install this sample project's dependencies with npm (hardhat @nomicfoundation/hardhat-toolbox)? (Y/n) › y

The @nomicfoundation/hardhat-toolbox plugin bundles all the commonly used packages and Hardhat plugins recommended to start developing with Hardhat.

Just in case it didn't install automatically, install this other requirement with the following command:

npm i @nomicfoundation/hardhat-toolbox

Next, install @axelar-network/axelar-gmp-sdk-solidity for Axelar General Message Passing SDK in Solidity and dotenv with the following command:

npm i @axelar-network/axelar-gmp-sdk-solidity dotenv

To ensure everything works, run the command below in the hardhat directory.

npx hardhat test

You will see a passed test result in your console.

Delete Lock.js from the test folder and delete deploy.js from the scripts directory. After that, go to contracts and delete Lock.sol.

The folders themselves should not be deleted!

Create a SendMessage.sol file inside the contracts directory and update it with the following code snippet. When using Hardhat, file layout is crucial, so pay attention!

// SPDX-License-Identifier: MIT
// SPDX license identifier specifies which open-source license is being used for the contract
pragma solidity 0.8.9;

// Importing external contracts for dependencies
import { AxelarExecutable } from '@axelar-network/axelar-gmp-sdk-solidity/contracts/executable/AxelarExecutable.sol';
import { IAxelarGateway } from '@axelar-network/axelar-gmp-sdk-solidity/contracts/interfaces/IAxelarGateway.sol';
import { IAxelarGasService } from '@axelar-network/axelar-gmp-sdk-solidity/contracts/interfaces/IAxelarGasService.sol';
import { IERC20 } from '@axelar-network/axelar-gmp-sdk-solidity/contracts/interfaces/IERC20.sol';

contract SendMessage is AxelarExecutable {

    string public value;
    string public sourceChain;
    string public sourceAddress;

    IAxelarGasService public immutable gasService;

    constructor(address gateway_, address gasReceiver_) AxelarExecutable(gateway_) {
        // Sets the immutable state variable to the address of gasReceiver_
        gasService = IAxelarGasService(gasReceiver_);
    }

    function sendMessage(
        string calldata destinationChain,
        string calldata destinationAddress,
        string calldata value_
    ) external payable {

        bytes memory payload = abi.encode(value_);

        if (msg.value > 0) {
            gasService.payNativeGasForContractCall{ value: msg.value }(
                address(this),
                destinationChain,
                destinationAddress,
                payload,
                msg.sender
            );
        }
        // Calls the Axelar gateway contract with the specified destination chain and address and sends the payload along with the call
        gateway.callContract(destinationChain, destinationAddress, payload);
    }

    function _execute(
        string calldata sourceChain_,
        string calldata sourceAddress_,
        bytes calldata payload_
    ) internal override {
        // Decodes the payload bytes into the string value and sets the state variable for this contract
        (value) = abi.decode(payload_, (string));

sourceChain = sourceChain_;
        sourceAddress = sourceAddress_;
    }
}

In the code snippet above you:

  • Created a SendMessage contract that extends the AxelarExecutable contract

  • Imported AxelarExecutable, IAxelarGateway, IAxelarGasService from the @axelar-network/axelar-gmp-sdk-solidity library.

  • Defined four state variables: value, sourceChain, sourceAddress, and

    gasService. The gasService state variable is immutable and can only be set during contract deployment.

  • Initialized the gasService variable with the provided gasReceiver_ address.

  • Created a sendMessage function that takes three string parameters: destinationChain, destinationAddress, value_ and it utilizes gasService.payNativeGasForContractCall with native gas (Ether).

  • Utilized the gateway contract's callContract function with the specified destinationChain, destinationAddress, and payload parameters.

  • Used the _execute function to decode the payload bytes into the value string and update the state variables sourceChain and sourceAddress.

Setup deployment script

Next, create a deploy.js file in the scripts folder and add the following code snippet:


const hre = require("hardhat");

async function main() {
  const SendMessage = await hre.ethers.getContractFactory("SendMessage");
  const sendMessage = await SendMessage.deploy(
    "",
    ""
  );

  await sendMessage.deployed();

  console.log(`SendMessage contract deployed to ${sendMessage.address}`);
}

// We recommend this pattern to be able to use async/await everywhere
// and properly handle errors.
main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

In the code snippet above:

  • The main function has the SendMessage contract factory obtained using hre.ethers.getContractFactory.

  • The sendMessage contract is deployed using the SendMessage.deploy method with two strings as arguments.

  • await sendMessage.deployed() statement ensures that the deployment is completed before moving forward.

  • The deployed contract's address is logged into the console.

Setup Remote Procedure Call (RPC) to Testnet

Remote Procedure Call (RPC) is a protocol used for communication between client and server systems in a network or blockchain environment. It enables clients to execute procedures or functions on remote servers and receive the results. RPC abstracts the underlying network details and allows clients to invoke methods on servers as if they were local.

Before you proceed to set up RPC, create a .env file using the command below:

touch .env

Ensure you are in the hardhat directory before running the command above.

Inside the .env file you just created, add the following key:

PRIVATE_KEY= // Add your account private key here

Getting your private account key is easy. Check out this post.

Next, set up RPC for Binance and Avalanche networks by updating the hardhat.config.js file with the following code snippet:

require("@nomicfoundation/hardhat-toolbox");
require("dotenv").config({ path: ".env" });
require("solidity-coverage");

const PRIVATE_KEY = process.env.PRIVATE_KEY;

// This is a sample Hardhat task. To learn how to create your own go to
// <https://hardhat.org/guides/create-task.html>
task("accounts", "Prints the list of accounts", async (taskArgs, hre) => {
  const accounts = await hre.ethers.getSigners();

  for (const account of accounts) {
    console.log(account.address);
  }
});

// You need to export an object to set up your config
// Go to <https://hardhat.org/config/> to learn more

/** @type import('hardhat/config').HardhatUserConfig */
module.exports = {
  solidity: "0.8.9",
  networks: {
    bsc: {
      url: "https://data-seed-prebsc-1-s1.binance.org:8545",
      chainId: 97,
      accounts: [PRIVATE_KEY],
    },
    avalancheFujiTestnet: {
      url: "https://avalanche-fuji-c-chain.publicnode.com",
      chainId: 43113,
      accounts: [PRIVATE_KEY],
    },
  },
  mocha: {
    timeout: 10000000000,
  },
};

You have successfully configured RPC for Binance and Avalanche test networks, you will proceed with the smart contract deployment to those networks in the following step.

Deploy Smart Contract to Binance and Avalanche Network

In this section, you will deploy the smart contract to Binance and Avalanche Testnet. However, before you proceed, you need to specify the Axelar Gateway Service and the Gas Service Contract in the SendMessage.deploy() method within the deploy.js file you created earlier.

You can find the Axelar Gas Service and Gateway contracts list for all the chains Axelar currently supports here.

You also need a faucet for your Binance and Avalanche accounts to ensure successful contract deployment. To obtain the Binance faucet, visit this link, and for the Avalanche faucet, access it here.

Deploy to Binance Testnet

Update the deploy.js file inside the scripts folder to deploy to Binance testnet with the following code snippet:

//...

async function main() {
  //...

  // Update arguments with the Axelar gateway and
  // gas service on Binance testnet
  const sendMessage = await SendMessage.deploy(
    "0x4D147dCb984e6affEEC47e44293DA442580A3Ec0",
    "0xbE406F0189A0B4cf3A05C286473D23791Dd44Cc6"
  );

  //...
}

//..

To deploy the contract on the Binance testnet, run the following command:

npx hardhat run scripts/deploy.js --network bsc

For example, the contract address will be displayed in your console: 0xC1b8fC9208E51aC997895626b0f384153E94f2A7.

Deploy to Avalanche Fuji Testnet

Update the deploy.js file inside the scripts folder to deploy to Avalanche testnet with the following code snippet:

//...

async function main() {
  //...

  // Update arguments with the Axelar gateway and
  // gas service on Avalanche testnet
  const sendMessage = await SendMessage.deploy(
    "0xC249632c2D40b9001FE907806902f63038B737Ab",
    "0xbE406F0189A0B4cf3A05C286473D23791Dd44Cc6"
  );

  //...
}

//..

To deploy the contract on the Avalanche testnet, run the following command:

npx hardhat run scripts/deploy.js --network avalancheFujiTestnet

The contract address will be displayed on your console; for example, 0x2a03DCB9B24431d02839822209D58262f5e5df52. Make sure to save both deployed contract addresses, as you will need them for front-end integration.

Integrating a Nextjs Frontend Application with Smart Contract

In the previous steps, you successfully built and deployed the smart contract. Now, it's time to interact with it from the front end, just as you would typically interact with decentralized applications on the web.

You already have the Next.js frontend project cloned, and the configuration for WAGMI and Rainbowkit is set up. This means you can proceed to update the existing application and connect your smart contract for testing.

Implementing Write Smart Contract Functionality

Interacting with our contract is quite simple from the front-end application, thanks to WAGMI, RainbowKit, and ethers.

Create a .env.local file in the root directory using the command below:

touch .env.local

Ensure you are in the root directory before running the command above.

Inside the .env.local file you just created, add the following key:

NEXT_PUBLIC_AVALANCHE_RPC_URL=https://avalanche-fuji-c-chain.publicnode.com
NEXT_PUBLIC_BSC_CONTRACT_ADDRESS=<BSC_CONTRACT_ADDRESS>
NEXT_PUBLIC_AVALANCHE_CONTRACT_ADDRESS=<AVALANCHE_CONTRACT_ADDRESS>

Replace <BSC_CONTRACT_ADDRESS> with the contract address, you deployed to the Binance testnet and replace <AVALANCHE_CONTRACT_ADDRESS> with the contract address, you deployed to the Avalanche Fuji Testnet earlier in this tutorial.

Next, implement the write functionality for the smart contract, add the following code snippet to the index.js file located in the pages directory.

//...
import SendMessageContract from "../hardhat/artifacts/contracts/SendMessage.sol/SendMessage.json";

const BSC_CONTRACT_ADDRESS = process.env.NEXT_PUBLIC_BSC_CONTRACT_ADDRESS;
const AVALANCHE_CONTRACT_ADDRESS =
  process.env.NEXT_PUBLIC_AVALANCHE_CONTRACT_ADDRESS;
const AVALANCHE_RPC_URL = process.env.NEXT_PUBLIC_AVALANCHE_RPC_URL;

export default function Home() {
//...

const [message, setMessage] = useState(""); 
const [sourceChain, setSourceChain] = useState(""); 

const { config } = usePrepareContractWrite({ // Calling a hook to prepare the contract write configuration
  address: BSC_CONTRACT_ADDRESS, // Address of the BSC contract
  abi: SendMessageContract.abi, // ABI (Application Binary Interface) of the contract
  functionName: "sendMessage", // Name of the function to call on the contract
  args: ["Avalanche", AVALANCHE_CONTRACT_ADDRESS, message], // Arguments to pass to the contract function
  value: ethers.utils.parseEther("0.01"), // Value to send along with the contract call for gas fee
});

const { data: useContractWriteData, write } = useContractWrite(config); 

const { data: useWaitForTransactionData, isSuccess } = useWaitForTransaction({ 
  hash: useContractWriteData?.hash, // Hash of the transaction obtained from the contract write data
});

const handleSendMessage = () => {
  write(); // Initiating the contract call

  toast.info("Sending message...", {
    position: "top-right",
    autoClose: 5000,
    hideProgressBar: false,
    closeOnClick: true,
    pauseOnHover: false,
    draggable: true,
  });
};

useEffect(() => {
    const body = document.querySelector("body");
    darkMode ? body.classList.add("dark") : body.classList.remove("dark");

    isSuccess
      ? toast.success("Message sent!", {
          position: "top-right",
          autoClose: 7000,
          closeOnClick: true,
          pauseOnHover: false,
          draggable: true,
        })
      : useWaitForTransactionData?.error || useContractWriteData?.error
      ? toast.error("Error sending message")
      : null;
  }, [darkMode, useContractWriteData, useWaitForTransactionData]);

return (
    //..
   )
}

In the code snippet above:

  1. Two state variables, message and sourceChain, are declared using the useState hook to hold the message content and source chain, respectively.

  2. The usePrepareContractWrite hook is called to prepare the configuration for a contract write operation. It takes several parameters, such as the BSC contract address, ABI, function name, arguments, and value.

  3. useContractWrite hook retrieves the contract write data and the write function based on the configuration obtained from the previous step.

  4. useWaitForTransaction hook is called to wait for the transaction to be mined. It takes the hash of the transaction obtained from the contract write data.

  5. handleSendMessage function is defined, which initiates the contract call by invoking the write function.

  6. useEffect hook performs actions when certain dependencies change to display toast notifications for successful or failed message sending.

Update the Send button and textarea with the following code snippet to send messages and retrieve the data to be sent.

//...

return (
    //...
    <div className="border border-gray-300 rounded-lg p-8 m-2 ">
        <h2 className="text-2xl font-bold mb-4">Send Message 📓 </h2>
        <textarea
            //...
            onChange={(e) => setMessage(e.target.value)}
        />
        <button
            //...
            onClick={() => handleSendMessage()}
         >
            Send
        </button>
     </div>
    //...
  );
}

Implementing Read Smart Contract Functionality

In the previous step, you implemented the functionality for writing to a smart contract from the front end. This section will teach you how to implement the functionality for reading data from a smart contract.

Update the index.js with the following code snippet:

//...

export default function Home() {
  //...

  const [value, setValue] = useState("");

  const provider = new ethers.providers.JsonRpcProvider(AVALANCHE_RPC_URL); // Create an instance of JsonRpcProvider with Avalanche RPC URL

  const contract = new ethers.Contract(
    AVALANCHE_CONTRACT_ADDRESS, 
    SendMessageContract.abi,

    provider
  );

  async function readDestinationChainVariables() {
    try {
      const value = await contract.value(); 
      const sourceChain = await contract.sourceChain();

      setValue(value.toString()); // Convert the value to a string and store it
      setSourceChain(sourceChain); // Store the source chain
    } catch (error) {
      toast.error("Error reading message"); // Display an error toast if reading fails
    }
  }

  useEffect(() => {
    readDestinationChainVariables(); // Call the function to read destination chain variables

    //...
  }, [darkMode, useContractWriteData, useWaitForTransactionData]);

  return (
    //...

        {value ? ( // Add value here
              <>
                //...
              </>
            ) : (
              <span className="text-red-500 ">waiting for response...</span>
         )}
  );
}

In the code above,

  1. An instance of the JsonRpcProvider class from the ethers.js library is created using the Avalanche RPC URL (AVALANCHE_RPC_URL) as the parameter.

  2. An instance of the Contract class from the ethers.js library is created, representing a contract on the Avalanche network. It takes parameters such as the contract address (AVALANCHE_CONTRACT_ADDRESS), ABI (SendMessageContract.abi), and provider.

  3. An asynchronous function named readDestinationChainVariables is defined. It attempts to read the contract's value and source chain variables using the value() and sourceChain() functions, respectively.

  4. If an error occurs during the reading process, a toast notification with the message "Error reading message" is displayed.

  5. The useEffect hook calls the readDestinationChainVariables function when certain dependencies change.

Trying the Application

Hurray 🥳 , you have successfully built and deployed a full-stack interchain decentralized application.

Trying the Fullstack Interchain Application

You can find the GMP transaction on Axelarscan Testnet here and the complete code for this project on GitHub.

What Next?

This post covered the utilization of Axelar's General Message Passing with callContract, but that's not all the General Message Passing can do.

You can always explore other functionalities like callContractWithToken, SendToken, Deposit addresses, NFT Linker, JavaScript SDK, etc.

If you've made it this far, you're awesome! You can also tweet about your experience building or following along with this tutorial to show your support to the author and tag @axelar.

Conclusion

This tutorial taught you how to build a full-stack interchain decentralized application using Next.js, Solidity, and Axelar's General Message Passing. You learned how to deploy and send messages from Binance to Avalanche test networks and interact with them through a Next.js frontend application.

Reference