Skip to content

Minting a Cross-Chain NFT with the Axelar SDK

by Jeremy Boetticher & Kevin Neilson

Introduction

Axelar’s general message passing (GMP) allows smart contracts to communicate securely across chains. This enables developers to build cross-chain connected applications on Moonbeam that can tap into functionality from Polkadot, Ethereum, Avalanche, Cosmos, and beyond. In this tutorial, we'll introduce the JavaScript SDK package Axelar packed with tools to aid developers in this cross-chain vision.

The AxelarJS SDK allows developers to estimate fees, track and recover transactions, and quickly transfer tokens. To show off some of the SDK's tools, we will walk through a demo that deploys an NFT that can be minted across chains. Before following along with the tutorial, you may wish to first familiarize yourself with this Overview of Axelar.

In this tutorial, we'll mint an NFT on a remote chain by using Axelar to send a specific message to trigger the mint. We'll be using the AxelarJS SDK in conjunction with a minting script that will define the parameters of the cross-chain mint, such as the destination chain, destination contract address, and more.

The information presented herein is for informational purposes only and has been provided by third parties. Moonbeam does not endorse any project listed and described on the Moonbeam docs website (https://docs.moonbeam.network/).

Axelar Refresher

Axelar is a blockchain that connects blockchains, delivering secure cross-chain communication. Every validator in Axelar’s network runs light nodes on chains that Axelar supports. In this demo, we'll interact with two Axelar contracts, one of which is the Axelar Gateway contract. The dynamic validator set uses this contract to monitor activity on each chain. Their role is crucial for achieving consensus, ensuring that messages are accurately transmitted from one chain to another

Axelar Diagram

The other contract we will be working with is the Axelar Gas Receiver microservice. Whenever you use the Axelar Gateway to send a cross-chain transaction, the Gas Receiver lets you pay for the subsequent transaction on the destination chain. Although not mandatory, this feature enables the end user to send just a single transaction. This transaction automatically updates the destination chain and allows all transaction fees to be paid using the source-chain token already held by the user.

Building the Cross-Chain NFT Contract

We'll be deploying a simple contract that can only mint an NFT if it receives a specific cross-chain message. Minting the NFT will require a token payment, which will be wrapped DEV (Moonbase Alpha’s native currency). A wrapped token for a native currency like DEV will mint one ERC-20 WDEV token for each DEV sent to it, and gives the option to redeem one WDEV token for one DEV. Using WDEV instead of native DEV is required because Axelar requires all tokens sent to be ERC-20s.

So to mint in the cross-chain message, it must receive at least 0.05 WDEV.

We’re putting the same contract on two chains, so it must send and receive messages. From a high level, our contract does two things:

  1. Send an encoded address message with WDEV across chains via Axelar’s Gateway with the option to pay for its gas on the destination chain
  2. Receive an encoded address message from Axelar, and execute only if it received at least 0.05 WDEV

You’ll be using a Hardhat project, but before we set it up, let’s first take a look at a few parts of the contract. I encourage you to follow along!

Contracts executed by the Axelar Gateway, like ours here, inherit from IAxelarExecutable. This parent contract has two overridable functions, _execute and _executeWithToken, that allow developers to change the logic when a contract receives a contract call from the Axelar Gateway. Both functions have the same inputs, but _executeWithToken also includes tokenSymbol and amount to describe the token being sent cross-chain.

Now let’s finally take a look at our mint function. It takes three inputs: a destination address, a destination chain, and the amount of WDEV to send. Remember that this mint function is called on the origin chain (Moonbase Alpha), which mints an NFT on a different destination chain.

mintXCNFT function
function mintXCNFT(
    string memory destinationAddress,
    string memory destinationChain,
    uint256 amount
) external payable {
    // Create the payload
    bytes memory payload = abi.encode(msg.sender);

    // Takes WDEV from the user and puts them into this contract for the Gateway to take        
    wDev.transferFrom(msg.sender, address(this), amount);
    wDev.approve(address(gateway), amount);

    // Pay for gas
    // This is a gas service SPECIFICALLY for sending with token
    gasService.payNativeGasForContractCallWithToken{value: msg.value}(
        address(this),
        destinationChain,
        destinationAddress,
        payload,
        "WDEV",
        amount,
        msg.sender
    );

    // Call remote contract
    gateway.callContractWithToken(
        destinationChain,
        destinationAddress,
        payload,
        "WDEV",
        amount
    );
}

The logic itself has three steps. First, it takes WDEV from the caller. The caller must approve our NFT contract to transfer their WDEV beforehand. Then, our NFT contract approves the gateway to transfer the WDEV from the caller since the gateway contract will try to transfer the tokens from our NFT contract in the final step.

Next, to pay for gas on the destination chain, we make use of the IAxelarGasService contract. This contract has many different configurations to pay for gas, like paying for execute versus executeWithToken or using an ERC-20 token as payment versus using native currency. Be careful if you plan on writing your own contract later!

In this case, since the origin chain is Moonbase Alpha, the native currency is DEV. We can use native DEV to pay for gas on the destination chain based on the conversion rates between Moonbase Alpha’s native currency and the destination chain’s native currency. Since we’re sending a contract call that includes a token to pay for destination gas in DEV, we will be using the payNativeGasForContractCallWithToken function.

Finally, we call the gateway to send our cross-chain message with callContractWithToken. Notice that the payload (generic data that sent in a cross-chain call) that we’re sending is just the caller’s address. This data will need to be decoded by the destination contract.

Now let’s take a look at what happens on the destination chain. Since we expect tokens to be sent as payment for an NFT mint, we will override _executeWithToken from IAxelarExecutable.

executeWithToken function
// Mints the NFT for the user
function _executeWithToken(
    string memory, /*sourceChain*/
    string memory, /*sourceAddress*/
    bytes calldata payload,
    string memory tokenSymbol,
    uint256 amount
) internal override {
    require(
        keccak256(abi.encodePacked(tokenSymbol)) == keccak256("WDEV"),
        "Only WDEV is accepted"
    );
    require(amount >= 0.05 ether, "Not enough to mint!");

    address user = abi.decode(payload, (address));

    _mint(user, currentNFTID);
    currentNFTID++;
}

In our implementation of _executeWithToken, we first check to ensure that the tokenSymbol provided by Axelar is “WDEV”. Then we expect 0.05 WDEV tokens for payment and will revert if any other token or anything less than 0.05 WDEV gets sent. Afterwards, we decode the payload to get the address of the origin chain’s caller so that we can mint an NFT to that address. Finally, we finish the minting!

You can find the full code for the CrossChainNFT.sol below.

CrossChainNFT.sol
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.9;

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@axelar-network/axelar-gmp-sdk-solidity/contracts/interfaces/IAxelarGasService.sol";
import { AxelarExecutable } from '@axelar-network/axelar-gmp-sdk-solidity/contracts/executable/AxelarExecutable.sol';

// Allows users to mint an NFT, but only cross chain
contract CrossChainNFT is ERC721, AxelarExecutable {
    constructor(
        address _gateway,
        IAxelarGasService _gasService,
        IERC20 _wDev
    ) ERC721("Cross Chain NFT", "XCNFT") AxelarExecutable(_gateway) {
        gasService = _gasService;
        wDev = _wDev;
    }

    uint256 currentNFTID;
    IAxelarGasService gasService;
    IERC20 wDev;

    // Mints the NFT for the user
    function _executeWithToken(
        string memory, /*sourceChain*/
        string memory, /*sourceAddress*/
        bytes calldata payload,
        string memory tokenSymbol,
        uint256 amount
    ) internal override {
        require(
            keccak256(abi.encodePacked(tokenSymbol)) == keccak256("WDEV"),
            "Only WDEV is accepted"
        );
        require(amount >= 0.05 ether, "Not enough to mint!");

        address user = abi.decode(payload, (address));

        _mint(user, currentNFTID);
        currentNFTID++;
    }

    function mintXCNFT(
        string memory destinationAddress,
        string memory destinationChain,
        uint256 amount
    ) external payable {
        // Create the payload
        bytes memory payload = abi.encode(msg.sender);

        // Takes WDEV from the user and puts them into this contract for the Gateway to take        
        wDev.transferFrom(msg.sender, address(this), amount);
        wDev.approve(address(gateway), amount);

        // Pay for gas 
        // This is a gas service SPECIFICALLY for sending with token
        gasService.payNativeGasForContractCallWithToken{value: msg.value}(
            address(this),
            destinationChain,
            destinationAddress,
            payload,
            "WDEV",
            amount,
            msg.sender
        );

        // Call remote contract
        gateway.callContractWithToken(
            destinationChain,
            destinationAddress,
            payload,
            "WDEV",
            amount
        );
    }
}

Setting Up the Repository

Make sure to clone the GitHub repository for this tutorial. We need to install some dependencies, including Hardhat, OpenZeppelin contracts, some Axelar contracts, and the Axelar SDK. To configure the dependencies properly, run the following command:

npm install

The repository contains two Solidity files. The first file is the CrossChainNFT as expected, and the second is an Axelar library StringAddressUtils.sol that doesn’t have an npm package yet but is still required for the Hardhat implementation.

There are also four Hardhat scripts within the repository’s scripts folder.

  • axelarStatus.js - a Hardhat task that lets you view information about Axelar transactions
  • deploy.js - deploys the CrossChainNFT to the network provided by Hardhat
  • gatewayGasReceiver.js - returns hardcoded values for Axelar’s Gateway and gas service contracts
  • mint.js - mints the CrossChainNFT (only run on Moonbase Alpha)

Before we get into the fun part, you will need to get an account with a private key funded with DEV to deploy the contract and sign all future transactions. Place this within a secrets.json file within the repository’s main directory. You should format it as follows:

{
    "privateKey": "INSERT_PRIVATE_KEY"
}

If everything goes well, you will be able to compile correctly:

npx hardhat compile

Deploying the Cross-Chain Contract to Moonbase Alpha

This demo focuses on using the scripts, so it’s best to take a look at them, starting with deploy.js, which is similar to the Ethers.js tutorial deployment contracts.

gatewayGasReceiver.js stores many of the contract addresses in this repo, which are necessary for the deployment. You likely will not have to change any of the hardcoded addresses. Try deploying your contract to the origin chain:

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

You should see the address deployed and printed in the console. Be sure to copy it! You will need it to interact with the next script. You also need to deploy it to the destination chain. The choice of which destination network to use is up to you, but you will need its native currency to deploy. I’ve included some of the available networks and their faucets here:

Network Faucet Deployment Command
Sepolia Faucet Link
npx hardhat run scripts/deploy.js --network sepolia
Polygon Mumbai Faucet Link
npx hardhat run scripts/deploy.js --network mumbai
Avalanche Fuji Faucet Link
npx hardhat run scripts/deploy.js --network fuji
Fantom TestNet Faucet Link
npx hardhat run scripts/deploy.js --network fantom

After running a deployment command, you'll see output like the below. Be sure to copy the destination chain's contract address because you'll need to provide that later.

npx hardhat run scripts/deploy.js --network moonbase
Compiled 1 Solidity file successfully Nothing to compile Deployed CrossChainNFT on moonbase at: 0xB1972a5487A0Af15C47d321f25E25E8E3c3e8462

Building the Mint.js Script

The minting contract is quite exciting and will require Axelar’s SDK. At the top of the mint.js script, Ethers.js is initialized in a Hardhat script. The Axelar SDK is also initialized. There are multiple Axelar APIs available in the SDK, but in this case we will only be using the Axelar Query API since it includes all of the gas estimation functionality that we’ll need for paying gas fees across chains.

const ethers = hre.ethers;
const axelarSDK = new AxelarQueryAPI({
  environment: Environment.TESTNET,
});

There are also some constants for you to change right after. This walkthrough is using Fantom as the destination chain, but you can use whichever chains you deployed to. Note that even though we’re using a TestNet environment, Axelar refers to the chain names by their MainNet equivalents, hence why the origin chain is moonbeam and not moonbase.

const ORIGIN_CHAIN = 'moonbeam';
const DESTINATION_CHAIN = 'ethereum-sepolia';
// Address of CrossChainNFT printed in the console after running deploy script
const ORIGIN_CHAIN_ADDRESS = 'INSERT_CONTRACT_ADDRESS';
// Address of AxelarAcceptEverything.sol on Sepolia, you can change this 
// contract address to your own AxelarAcceptEverything.sol contract 
const DESTINATION_CHAIN_ADDRESS = '0x89f801C7DB23439FDdBad4f913D788F13d1d7494'; 

Next, we have to work with wrapped DEV to send across chains. First, we must wrap our DEV, and then we approve the contract on the origin chain to take some of our WDEV. This is necessary because the origin chain’s contract has to send your WDEV to pay for minting the NFT on the destination chain.

Note here that instead of hardcoding the WDEV contract address, we’re using the IAxelarGateway contract to find the address. We could have also done this in the smart contract, but I wanted to show off how you would do it with Ethers.js. As expected, we sign two transactions: first to wrap 0.13 WDEV, then to approve our CrossChainNFT contract to send that WDEV.

You may be wondering why we’re wrapping 0.13 WDEV when the price of the mint is only 0.05. At the time of writing, Axelar collects a small fee (0.08 WDEV in this case) when transferring tokens between networks, which can be calculated on their website. Gateways do this automatically, but this responsibility may be delegated to the IAxelarGasServicecontract in the future.

const MOONBASE_WDEV_ADDRESS = await gateway.tokenAddresses('WDEV');

// Wrap + Approve WDEV to be used by the NFT contract
// wrap => transfer to contract => contract transfers to Gateway
const wDEVPayment = ethers.utils.parseUnits('0.13', 'ether');
const wDEV = await ethers.getContractAt('WETH9', MOONBASE_WDEV_ADDRESS);

const wrapTx = await wDEV.deposit({ value: wDEVPayment });
console.log('Wrap transaction hash: ', wrapTx.hash);

const approveTx = await wDEV.approve(ORIGIN_CHAIN_ADDRESS, wDEVPayment);
console.log('Approve transaction hash: ', approveTx.hash);

console.log('Awaiting transaction confirmations...');
await ethers.provider.waitForTransaction(approveTx.hash, 1);

Now we have to estimate the amount of DEV that we send to the mintXCNFT function to pay for gas on the destination chain. This is where the Axelar SDK kicks in.

We must estimate the amount of gas to spend on the destination chain because it is difficult to estimate a function that can only be called by a specific contract. In this case, we overestimate the amount of gas we will spend as 400,000. In an actual production environment, you should benchmark the amount of gas that you spend. However, if you do end up overestimating by a lot, you will get refunded by Axelar’s gas services.

The estimateGasFee function provided by the Axelar SDK will find the conversion between the origin chain’s native currency and the destination chain’s native currency to find the right amount to send to the destination chain.

You, the astute reader, might wonder why we’re using GLMR instead of DEV. Similar to how Axelar uses the MainNet chain names instead of using the TestNet names, Axelar will interpret GLMR as DEV since we’re using the TestNet environment.

const estimateGasUsed = 400000;
const gasFee = await axelarSDK.estimateGasFee(
  ORIGIN_CHAIN,
  DESTINATION_CHAIN,
  GasToken.GLMR,
  estimateGasUsed
);
const gasFeeToHuman = ethers.utils.formatEther(ethers.BigNumber.from(gasFee));
console.log(`Cross-Chain Gas Fee: ${gasFee} Wei / ${gasFeeToHuman} Ether`);

Calling this function from the SDK will return a string representing the amount of DEV WEI to pay, like 241760932800000. That’s hard for us simple humans to understand, so we use Ethers.js to convert it into a more human-readable version to print to the console later.

const gasFeeToHuman = ethers.utils.formatEther(ethers.BigNumber.from(gasFee));

Finally, we call the mintXCNFT contract function. The important takeaway here is that we’re sending the gas fee not as a gas limit but as value. Ethers.js can calculate how much gas to send on the origin chain. However, to pay for the destination chain, we have to calculate with the Axelar SDK and send it as value to the IAxelarGasReceiver contract.

// Begin the minting
const mintRes = await nft.mintXCNFT(
  DESTINATION_CHAIN_ADDRESS,
  DESTINATION_CHAIN,
  wDEVPayment,
  { value: gasFee }
);
console.log('Minting transaction hash: ', mintRes.hash);

That’s the entire script! Before we run the script, check again to make sure that the four constants (ORIGIN_CHAIN, DESTINATION_CHAIN, ORIGIN_CHAIN_ADDRESS, DESTINATION_CHAIN_ADDRESS) at the top of the script are set correctly.

Here’s the command to mint your NFT!

npx hardhat run scripts/mint.js --network moonbase

The console should output something similar to this:

npx hardhat run scripts/mint.js --network moonbase
Nothing to compile Wrap transaction hash: 0x89bd6c42c9b7791ce51a0ef74e83fa46fc063eefcd838def3664cb12970156cd
Approve transaction hash: 0x1594d43444d1f8abdadcab78098452d8b25a9eed72c89597c1b9822b5a8e1605
Awaiting transaction confirmations... Cross-Chain Gas Fee: 1694247418108372848 Wei / 1.694247418108372848 Ether Minting transaction hash: 0x9daa8c762dc3c60c5ef009486fbd6c4e91baa55baec79db2be6e7d10cfc06c4c

The most important data here is the minting transaction because that’s how you track your transaction's status. So don’t lose it! But if you do, you can look at all of the recent transactions on Axelar’s TestNet scanner.

Viewing Axelar Transaction Status

Axelar has a TestNet explorer, and a successful transaction for the interaction you just completed would look something like this:

Viewing Transaction status on AxelarScan

It's a good idea to try out the SDK to view the status of your transactions because it gives more information about your transaction and any possible errors. To do this, I wrote a Hardhat task for us to use. You can view the code in axelarStatus.js, but we’ll take a dive here too.

The main meat of the code is in these five lines. First, we initialize the SDK module that we will be using, the AxelarGMPRecoveryAPI. Unlike the AxelarQueryAPI that we used in the minting script, the AxelarGMPRecoveryAPI helps track and recover stalled transactions. Next, we have to query the transaction status, and the SDK takes care of it for us.

const sdk = new AxelarGMPRecoveryAPI({
  environment: Environment.TESTNET,
});
const txStatus = await axelarSDK.queryTransactionStatus(txHash);
console.log(txStatus);

You can learn a bit more about the AxelarGMPRecoveryAPI in Axelar’s documentation. It includes additional functionality in case a transaction goes wrong, especially if there isn’t enough gas sent along with the cross-chain transaction.

The axelarStatus.js file is configured as a Hardhat task rather than a script, which means that the command to run it will differ slightly from the command style required to execute a script. Be sure to note these differences and carefully craft the below command, replacing INSERT_TRANSACTION_HASH with the transaction of hash on the origin chain that you sent a cross-chain message in:

npx hardhat axelarStatus --tx INSERT_TRANSACTION_HASH

If you run the Hardhat script, you’ll end up with something like this in your console (I didn’t include all of it since it’s so large). You’re likely most interested in the status, where a list of possible ones is in Axelar’s documentation. You’re looking for destination_executed to indicate that it was received and executed correctly, but if you’re too early you might find source_gateway_called or destination_gateway_approved.

Status: 'source_gateway_called' Error: undefined Total Time Spent: { total: 39 }
Gas Paid Info: Status: 'gas_paid' Block Hash: '0x4e9ec37b8ebfb2acb7180aff401493dfb3025439c42225c2908f984b54131baa' Chain: 'moonbeam' Address: '0xbE406F0189A0B4cf3A05C286473D23791Dd44Cc6' Transaction Hash: '0xeb3dc836b890bded6c9e9ce103cdbac69843c55fb19ade1771936d44b3bb9151' Event: 'NativeGasPaidForContractCallWithToken' Event Signature: 'NativeGasPaidForContractCallWithToken(address,string,string,bytes32,string,uint256,uint256,address)' Transaction Index: 0 Event Index: 2 Block Number: 7963797 Block Timestamp: 1721782902
Call Transaction Details: Chain: 'moonbeam' Contract Address: '0x5769D84DD62a6fD969856c75c7D321b84d455929' Transaction Hash: '0xeb3dc836b890bded6c9e9ce103cdbac69843c55fb19ade1771936d44b3bb9151' Block Number: 7963797 Log Index: 4 Event: 'ContractCallWithToken' Sender: '0x1AE99204240C92DE9B01207Ed5dF777E4e738e05' Destination Chain: 'ethereum-sepolia' Destination Contract Address: '0x89f801C7DB23439FDdBad4f913D788F13d1d7494' Payload Hash: '0x4af6b315b6befdc046499484184d0fe2f273e733bfdb5927aeb5872b8a3761f7' Symbol: 'WDEV' Amount: '130000000000000000'
Transaction Receipt Details: Block Hash: '0x4e9ec37b8ebfb2acb7180aff401493dfb3025439c42225c2908f984b54131baa' Transaction Index: 0 Gas Used: 813360 Status: 1 From: '0x3b939fead1557c741ff06492fd0127bd287a421e' To: '0x1ae99204240C92DE9B01207Ed5dF777E4e738e05' Effective Gas Price: 125000000

You can learn more about debugging contracts in Axelar’s documentation, where they go into depth on specific error messages and how to use tools like Tenderly for logic errors.

Conclusion

You’re well on your way to creating your own connected contracts with Axelar! Learn more about Axelar on their docs site, and read about how Moonbeam is shaping up to be the leader in blockchain interoperability in our introduction to connected contracts. For more information on the AxelarJS SDK, be sure to check out the Axelar Docs.

This tutorial is for educational purposes only. As such, any contracts or code created in this tutorial should not be used in production.
The information presented herein has been provided by third parties and is made available solely for general information purposes. Moonbeam does not endorse any project listed and described on the Moonbeam Doc Website (https://docs.moonbeam.network/). Moonbeam Foundation does not warrant the accuracy, completeness or usefulness of this information. Any reliance you place on such information is strictly at your own risk. Moonbeam Foundation disclaims all liability and responsibility arising from any reliance placed on this information by you or by anyone who may be informed of any of its contents. All statements and/or opinions expressed in these materials are solely the responsibility of the person or entity providing those materials and do not necessarily represent the opinion of Moonbeam Foundation. The information should not be construed as professional or financial advice of any kind. Advice from a suitably qualified professional should always be sought in relation to any particular matter or circumstance. The information herein may link to or integrate with other websites operated or content provided by third parties, and such other websites may link to this website. Moonbeam Foundation has no control over any such other websites or their content and will have no liability arising out of or related to such websites or their content. The existence of any such link does not constitute an endorsement of such websites, the content of the websites, or the operators of the websites. These links are being provided to you only as a convenience and you release and hold Moonbeam Foundation harmless from any and all liability arising from your use of this information or the information provided by any third-party website or service.
Last update: July 24, 2024
| Created: February 21, 2024