Build a Full Stack Interchain dApp with Next.js, Solidity & Axelar
Today's blockchain ecosystem is characterized by many blockchain networks operating independently, each with unique features and functionalities. However, this fragmentation presents challenges regarding seamless communication and collaboration between these networks. 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 to send messages from one blockchain to another.
For a quick start, you can find the complete code for this tutorial on GitHub. However, in the video provided below, 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's General Message Passing (GMP) feature empowers developers by enabling them to call any function on interconnected chains seamlessly.
With GMP, developers gain the ability to:
Call a contract on chain A and interact with a contract on chain B.
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.
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
Let's get a sample project by running the command below:
npx hardhat
We'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 our 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 we:
Create a
SendMessage
contract that extends theAxelarExecutable
contractImport
AxelarExecutable
,IAxelarGateway
,IAxelarGasService
from the@axelar-network/axelar-gmp-sdk-solidity
library.Define
four
state variables:value
,sourceChain
,sourceAddress
, andgasService
. ThegasService
state variable is immutable and can only be set during contract deployment.Initialize the
gasService
variable with the providedgasReceiver_
address.Create a
sendMessage
function that takes three string parameters:destinationChain
,destinationAddress
,value_
and it utilizesgasService.payNativeGasForContractCall
with native gas (Ether).Utilized the
gateway
contract'scallContract
function with the specifieddestinationChain
,destinationAddress
, andpayload
parameters.The
_execute
function decodes the payload bytes into thevalue
string and updates the state variablessourceChain
andsourceAddress
.
Setup Deployment Script
Next, create a deploy.js
file in the scripts
folder and add the following code snippet:
// We require the Hardhat Runtime Environment explicitly here. This is optional
// but useful for running the script in a standalone fashion through `node <script>`.
//
// You can also run a script with `npx hardhat run <script>`. If you do that, Hardhat
// will compile your contracts, add the Hardhat Runtime Environment's members to the
// global scope, and execute the script.
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 theSendMessage
contract factory obtained usinghre.ethers.getContractFactory
.The
sendMessage
contract is deployed using theSendMessage.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.
//...
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:
Two state variables,
message
andsourceChain
, are declared using theuseState
hook to hold the message content and source chain, respectively.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.useContractWrite
hook retrieves the contract write data and the write function based on the configuration obtained from the previous step.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.handleSendMessage
function is defined, which initiates the contract call by invoking thewrite
function.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,
An instance of the
JsonRpcProvider
class from the ethers.js library is created using the Avalanche RPC URL (AVALANCHE_RPC_URL
) as the parameter.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.An asynchronous function named
readDestinationChainVariables
is defined. It attempts to read the contract's value and source chain variables using thevalue()
andsourceChain()
functions, respectively.If an error occurs during the reading process, a toast notification with the message "Error reading message" is displayed.
The
useEffect
hook calls thereadDestinationChainVariables
function when certain dependencies change.
Trying the Application
Hurray 🥳 , you have successfully built and deployed a full-stack interchain decentralized 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 @axelarcore.
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
Subscribe to my newsletter
Read articles from Idris Olubisi directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Idris Olubisi
Idris Olubisi
Software Engineer | Developer Advocate | Technical Writer | Content Creator