How to Build a Cross-Chain Airdrop DApp With Solidity, Next.js and Axelar

In this tutorial, you will learn how to build a decentralized application (dApp) for cross-chain airdrops using Solidity, Next.js and Axelar General Message Passing for distributing tokens across multiple chains

NEWS
TECHNOLOGY
August 21, 2023
Idris Olubisi
August 21, 2023

Table of Contents

Back to blog

Airdrops are part of a strategy for distributing coins or tokens to wallet addresses. In Web3, airdrop serve as a method to reward community members for their contributions and promote adoption, which involves distributing newly minted tokens to thousands of distinct wallet addresses simultaneously. While this has typically been done on a single blockchain in the past, you will learn how to execute a cross-chain airdrop that extends this process across multiple chains.

In this tutorial, you will learn how to build a cross-chain airdrop decentralized application (dApp) using Solidity, Next.js, and Axelar General Message Passing (GMP) for distributing tokens across multiple chains.

What are we building? This application guides you through a simple four-step process that empowers users to:

  • Connect their wallet.
  • Authorize the airdrop token amount for spending.
  • Add wallet addresses.
  • Distribute tokens from Polygon to Avalanche testnet via airdrop.

To get started quickly, you'll find the entire code for this tutorial on GitHub. This way, you can explore the inner workings of the application as you follow along.

Cross-chain

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 using the following command:

git clone https://github.com/axelarnetwork/cross-chain-airdrop-dapp.git

Make sure you're on the start branch using the following command:

git checkout starter

Next, change the directory into the cloned folder and install the project locally using npm with the following command:

cd cross-chain-airdrop-dapp && npm i && npm run dev

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

How

To successfully create a smart contract for an airdrop using Axelar's general message passing, it's important to understand how it enables cross-chain interaction. The following section will explain how it works.

Getting Started with Axelar General Message Passing (GMP)

Axelar's General Message Passing (GMP) feature empowers developers 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.

Building a smart contract using Hardhat and Axelar GMP

In this section, you will build the smart contract leveraging Axelar GMP to airdrop tokens from the Polygon testnet to the Avalanche testnet.

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

Accept the following options:

Accept

The @nomicfoundation/hardhat-toolbox plugin includes all the commonly used packages and recommended Hardhat plugins for starting development with Hardhat.

In case it wasn't installed automatically, install this additional requirement using the following command:

npm i @nomicfoundation/hardhat-toolbox@3.0.0

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@3.6.1 dotenv

To ensure everything functions properly, execute the following command within the hardhat directory.

npx hardhat test

You will see a passed test result in your console.

To start building from scratch, it's important to clean up the directory. To accomplish this task, delete Lock.js from the test folder and remove deploy.js from the scripts directory. After that, navigate to the contracts folder and delete Lock.sol.

Delete

The folders themselves should not be deleted!

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

// SPDX-License-Identifier: MIT pragma solidity ^0.8.9; 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 {IERC20} from "@axelar-network/axelar-gmp-sdk-solidity/contracts/interfaces/IERC20.sol"; import {IAxelarGasService} from "@axelar-network/axelar-gmp-sdk-solidity/contracts/interfaces/IAxelarGasService.sol"; // Airdrop contract that inherits from AxelarExecutable contract Airdrop is AxelarExecutable { // Immutable reference to the gas service contract IAxelarGasService public immutable gasService; // Variables to track airdrop details uint256 public amountReceived; address[] public airdropRecipients; // Constructor to initialize the contract constructor(address gateway_, address gasReceiver_) AxelarExecutable(gateway_) { // Initialize the gas service contract gasService = IAxelarGasService(gasReceiver_); } // Function to initiate airdrop to multiple recipients on another chain function sendToMany( string memory destinationChain, string memory destinationAddress, address[] calldata destinationAddresses, string memory symbol, uint256 amount ) external payable { // Require a gas payment for the transaction require(msg.value > 0, "Gas payment is required"); // Get the token address associated with the provided symbol address tokenAddress = gateway.tokenAddresses(symbol); // Transfer tokens from sender to this contract IERC20(tokenAddress).transferFrom(msg.sender, address(this), amount); // Approve the gateway to spend tokens on behalf of this contract IERC20(tokenAddress).approve(address(gateway), amount); // Encode the recipient addresses into a payload bytes memory payload = abi.encode(destinationAddresses); // Pay for native gas using the gas service contract gasService.payNativeGasForContractCallWithToken{value: msg.value}( address(this), destinationChain, destinationAddress, payload, symbol, amount, msg.sender ); // Initiate a contract call on the gateway gateway.callContractWithToken( destinationChain, destinationAddress, payload, symbol, amount ); } // Function to retrieve the list of airdrop recipients function getRecipients() public view returns (address[] memory) { return airdropRecipients; } // Internal function to execute airdrop on the current chain function _executeWithToken( string calldata, string calldata, bytes calldata payload, string calldata tokenSymbol, uint256 amount ) internal override { // Decode the payload to get the recipient addresses address[] memory recipients = abi.decode(payload, (address[])); // Get the token address associated with the provided symbol address tokenAddress = gateway.tokenAddresses(tokenSymbol); // Set amountReceived and airdropRecipients variables amountReceived = amount; airdropRecipients = recipients; // Calculate the amount of tokens to send to each recipient uint256 sentAmount = amount / recipients.length; // Transfer tokens to each recipient for (uint256 i = 0; i < recipients.length; i++) { IERC20(tokenAddress).transfer(recipients[i], sentAmount); } } }

In the code snippet above:

  • It defines a Solidity contract named Airdrop that extends the AxelarExecutable contract.
  • The contract imports several Solidity interfaces and contracts from the @axelar-network/axelar-gmp-sdk-solidity package.
  • The constructor of the contract takes two parameters: gateway_ and gasReceiver_. It initializes the gasService variable with the gasReceiver_ address and calls the constructor of the AxelarExecutable contract with the gateway_ address.
  • The contract has a function name
  • sendToMany that is external and payable. It takes several parameters, including destination chain, destination address, destination addresses (array),
  • symbol and amount. It requires that the message value (attached Ether) is greater than 0, transfers tokens from the sender to the contract, approves the transfer to the gateway, and performs a native gas payment using the gasService contract. It then calls the callContractWithToken function of the gateway contract.
  • The contract has a public function name and returns the array of airdrop recipients.
  • The contract has an internal function name _executeWithToken that is called by the
  • AxelarExecutable contract. It takes parameters including a payload (an array of addresses, which we have chosen in this tutorial but it can store any other data types) and a token symbol.
  • It decodes the payload, transfers tokens to the recipients, and updates the amountReceived and airdropRecipients variables.

Set up deployment script

Create a deploy.js file in the scripts folder and add the following code snippet:

const hre = require("hardhat"); async function main() { const Airdrop = await hre.ethers.deployContract("Airdrop", [ "", "", ]); await Airdrop.waitForDeployment(); console.log(`Airdrop contract deployed to ${await Airdrop.getAddress()}`); } // 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 Airdrop contract deployed using the deployContract, a method with two strings as arguments.
  • The await Airdrop.waitForDeployment() statement ensures that the deployment is completed before moving forward.
  • The deployed contract's address is logged into the console.

Set up remote procedure call (RPC) to testnet

A 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. If you use MetaMask, take a look at this post. Keep in mind that exporting the private key can differ for other wallet providers.

Next, set up RPC for Polygon and Avalanche test 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: { mumbai: { url: "https://rpc.ankr.com/polygon_mumbai", chainId: 80001, accounts: [PRIVATE_KEY], }, avalancheFujiTestnet: { url: "https://avalanche-fuji-c-chain.publicnode.com", chainId: 43113, accounts: [PRIVATE_KEY], }, }, };

You have successfully configured the RPC for Polygon and Avalanche test networks. In the next step, you will deploy smart contracts to these networks.

Deploy smart contract to Polygon and Avalanche networks

In this section, you will deploy the smart contract to Polygon and Avalanche testnets. However, before you proceed, you need to specify the Axelar Gateway Service and the Gas Service contract in the hre.ethers.deployContract() method within the deploy.js file you created earlier.

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

To ensure successful contract deployment, you also need a faucet for your Polygon and Avalanche testnet accounts. To get the Polygon faucet, visit this link; and for the Avalanche faucet, access it here.

Deploy to Polygon testnet

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

//... async function main() { // Update arguments with the Axelar gateway and // gas service on Polygon testnet const Airdrop = await hre.ethers.deployContract("Airdrop", [ "0xBF62ef1486468a6bd26Dd669C06db43dEd5B849B", "0xbE406F0189A0B4cf3A05C286473D23791Dd44Cc6", ]); //... } //..

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

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

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

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 Airdrop = await hre.ethers.deployContract("Airdrop", [ "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, 0x86bE218aD2CC1Dc3270c4A594E7BA52Fd03d6a46. Save both deployed contract addresses, as you will need them for frontend integration.

Integrating a Next.js 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 frontend, just as you would typically engage with dApps on the web.

Having already cloned the Next.js frontend project and set up the configuration for WAGMI and Rainbowkit, you can now move forward with updating the existing application and connecting your smart contract for testing, enabling interaction with it as you would with decentralized web applications.

Implementing smart-contract write functionality

Interacting with our contract is relatively straightforward from the frontend application, thanks to WAGMI, RainbowKit, and Ethers.

To interact between the Polygon and Avalanche testnets, you will need the Avalanche Fuji testnet RPC URL, the Polygon contract address and the Avalanche contract address. Create a .env.local file in the root directory by 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:

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

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

Next, you need to implement the write functionality for the smart contract. In this case, you must specify the airdrop amount and approve it by granting the contract permission to spend it. Only then can you input the wallet addresses to receive the airdrop and send it to them.

To enable the approval and airdrop feature, simply add this code snippet into the pages directory's index.js file. Be sure to import all essential functions from WAGMI, including the @axelar-network/axelarjs-sdk, Polygon, Avalanche Fuji testnet contract addresses, Airdrop contract and Avalanche Fuji testnet RPC URL.

//... import { useContractWrite, useContractRead, usePrepareContractWrite, useWaitForTransaction, erc20ABI, useAccount, } from "wagmi"; import { ethers } from "ethers"; import { AxelarQueryAPI, Environment, EvmChain, GasToken, } from "@axelar-network/axelarjs-sdk"; import AirdropContract from "../hardhat/artifacts/contracts/Airdrop.sol/Airdrop.json"; const POLYGON_CONTRACT_ADDRESS = process.env.NEXT_PUBLIC_POLYGON_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() { //... }

Next, create the following state variables and functions to interact with the smart contract you created earlier.

//... export default function Home() { const [darkMode, setDarkMode] = useState(false); const [amount, setAmount] = useState(0); const [Addresses, setAddresses] = useState(""); const { address } = useAccount(); const [isSendButtonVisible, setIsSendButtonVisible] = useState(false); const [isApproveButtonVisible, setIsApproveButtonVisible] = useState(true); const [isTextareaVisible, setIsTextareaVisible] = useState(false); const api = new AxelarQueryAPI({ environment: Environment.TESTNET }); const [gasFee, setGasFee] = useState(0); const toastOptions = { position: "top-right", autoClose: 8000, closeOnClick: true, pauseOnHover: false, draggable: true, }; // Approve aUSDC to be spent by the contract const { data: useContractWriteUSDCData, write: approveWrite } = useContractWrite({ address: "0x2c852e740B62308c46DD29B982FBb650D063Bd07", // Address of the aUSDC contract on Polygon abi: erc20ABI, functionName: "approve", args: [ POLYGON_CONTRACT_ADDRESS, ethers.utils.parseUnits(amount.toString(), 6), ], }); const { data: useWaitForTransactionUSDCData, isSuccess: isUSDCSuccess } = useWaitForTransaction({ hash: useContractWriteUSDCData?.hash, }); // Check Allowance const { data: readAllowance, isError: isAllowanceError, isLoading: isAllowanceLoading, } = useContractRead({ address: "0x2c852e740B62308c46DD29B982FBb650D063Bd07", // Address of the aUSDC contract on Polygon abi: erc20ABI, functionName: "allowance", args: [address, POLYGON_CONTRACT_ADDRESS], }); // Estimate Gas const gasEstimator = async () => { const gas = await api.estimateGasFee( EvmChain.POLYGON, EvmChain.AVALANCHE, GasToken.MATIC, 700000, 2 ); setGasFee(gas); }; // Send Airdrop const { data: useContractWriteData, write } = useContractWrite({ address: POLYGON_CONTRACT_ADDRESS, abi: AirdropContract.abi, functionName: "sendToMany", args: [ "Avalanche", AVALANCHE_CONTRACT_ADDRESS, Addresses.split(","), "aUSDC", ethers.utils.parseUnits(amount.toString(), 6), ], value: gasFee, }); const { data: useWaitForTransactionData, isSuccess } = useWaitForTransaction({ // Calling a hook to wait for the transaction to be mined hash: useContractWriteData?.hash, }); //... }

In the code above,

  • The useState hook was utilized to create several state variables, including darkMode, amount, Addresses, isSendButtonVisible, isApproveButtonVisible, isTextareaVisible and gasFee.
  • The component utilizes the useAccount hook to get the address variable.
  • An instance of AxelarQueryAPI is created with the TESTNET environment and assigned to the api variable.
  • The component sets up a toastOptions object used for displaying toast notifications.
  • The code uses the useContractWrite hook twice, first to Approve aUSDC to be spent by the contract and then to sendToMany in the Airdrop contract with various arguments.
  • It also uses the useWaitForTransaction hook to wait for the transactions to be mined, checking for success in both cases.

Next, you can create a function to handle sending the airdrop, managing the Approve functionality, and utilizing the useEffect hook.

//... export default function Home() { //... // Handle send airdrop button const handleSendAirdrop = async () => { if (!(amount && Addresses)) { toast.error("Please enter amount and addresses", toastOptions); return; } if (isAllowanceError) { toast.error("Error checking allowance", toastOptions); return; } write(); toast.info("Sending Airdrop...", { ...toastOptions, }); }; // Handle Approval const handleApprove = () => { if (!amount) { toast.error("Please enter amount", toastOptions); return; } approveWrite(); toast.info("Approving...", toastOptions); }; useEffect(() => { //... // The gas estimator gasEstimator(); isSuccess ? toast.success("Airdrop sent!", { toastOptions, }) : useWaitForTransactionData?.error || useContractWriteData?.error ? toast.error("Error sending message") : null; if (isUSDCSuccess) { toast.success("USDC Approved!", { toastOptions }); setIsApproveButtonVisible(false); setIsSendButtonVisible(true); setIsTextareaVisible(true); } else if ( useWaitForTransactionUSDCData?.error || useContractWriteUSDCData?.error ) { toast.error("Error approving USDC", { toastOptions }); } }, [ //... useContractWriteData, useWaitForTransactionData, useContractWriteUSDCData, useWaitForTransactionUSDCData, ]); //... return ( //... ) }

Update the Approve, Send button and textarea with the following code snippet to approve and send the airdrop cross-chain.

//... export default function Home() { //... return ( <div className="container mx-auto px-4 flex flex-col min-h-screen"> <header className="py-4"> {/* ... */} </header> <main className="flex-grow flex flex-col items-center justify-center"> {/* ... */} {isTextareaVisible && ( <div className="flex flex-col mb-4"> <label className="font-semibold mb-2">Addresses</label> <textarea placeholder="Enter addresses (separate with a comma)" className="border border-gray-300 rounded-lg p-2 h-32" onChange={(e) => setAddresses(e.target.value)} /> </div> )} {isApproveButtonVisible && ( <button className="bg-blue-500 hover:bg-blue-600 text-white py-2 px-6 rounded-full mr-5" onClick={() => handleApprove()} display="none" > Approve </button> )} {isSendButtonVisible && ( <button className="bg-blue-500 hover:bg-blue-600 text-white py-2 px-6 rounded-full" onClick={handleSendAirdrop} > Send </button> )} {/* . */} </main> <ToastContainer /> <footer className="flex justify-center items-center py-8 border-t border-gray-300"> {/* . */} </footer> </div> ); }

You can test the send functionality to see what you have so far, but you won't be able to retrieve it on the destination chain because the retrieval functionality hasn't been implemented yet.

The image below illustrates this by showing a waiting for response... message. Don't worry; you'll implement the retrieval functionality in the next step.

Integrating

Implementing smart-contract read functionality

In the previous step, you learned how to write to a smart contract from the frontend, and now this section will guide you through implementing the functionality to read data from a smart contract.

Update the index.js with the following code snippet to read the airdrop details, including the recipient's addresses and amount.

//... export default function Home() { //... const [amountReceived, setAmountReceived] = useState(0); const [airdropRecipients, setAirdropRecipients] = useState([]); //... // Read data from Avalanche const provider = new ethers.providers.JsonRpcProvider(AVALANCHE_RPC_URL); const contract = new ethers.Contract( AVALANCHE_CONTRACT_ADDRESS, AirdropContract.abi, provider ); async function readDestinationChainVariables() { try { const amountReceived = await contract.amountReceived(); const airdropRecipients = await contract.getRecipients(); setAmountReceived(amountReceived.toString()); setAirdropRecipients(airdropRecipients); } catch (error) { console.log(error); } } useEffect(() => { //... readDestinationChainVariables(); }, [ //... ]); //... }

In the code above,

  • Two state variables, amountReceived and airdropRecipients , were set up using the
  • useState hook to store the amount and recipients.
  • An instance of ethers.providers.JsonRpcProvider was created to connect to the Avalanche blockchain.
  • Defines a contract instance using ethers.Contract with the specified Avalanche contract address and ABI for the AirdropContract.
  • The function readDestinationChainVariables is defined to read data from the Avalanche contract, including the amount received and the list of airdrop recipients.
  • Inside useEffect, the readDestinationChainVariables function is called to fetch and update the state variables when the component mounts and when other dependencies change.

Next, update the UI to be r