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.
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
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:
- 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
- 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 transactionsdeploy.js
- deploys theCrossChainNFT
to the network provided by HardhatgatewayGasReceiver.js
- returns hardcoded values for Axelar’s Gateway and gas service contractsmint.js
- mints theCrossChainNFT
(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 |
|
Polygon Mumbai | Faucet Link |
|
Avalanche Fuji | Faucet Link |
|
Fantom TestNet | Faucet Link |
|
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.
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 IAxelarGasService
contract 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:
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:
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
.
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.
| Created: February 21, 2024