Table of Contents
In this tutorial, we will show you how to use Axelar services and functions to build a dApp that calls a contract on chain B from chain A and transfer tokens, for example to purchase an NFT, to send an airdrop, or to distribute tokens from a DAO. The contract on chain B can do anything, as long as it implements the IAxelarExecutable
interface.
- Prefer video? Watch Axelar engineers in a live coding demo
- Ready to skip ahead? Visit docs.axelar.dev and check out our repo of example dApps on GitHub.
1. Install nodejs.
The first thing you’ll want to do is to install Node if you don’t have it already.
Run node -v
to check your installation. It should show something like this:
2. Clone the axelar-local-gmp-examples repo.
We have created a library of example apps — go ahead and clone it onto your computer.
git clone <https://github.com/axelarnetwork/axelar-local-gmp-examples.git>
3. Build contracts and tests.
Install all the node modules and build the contracts.
npm update && npm install npm run build
4. Understand the call-contract-with-tokens
code.
💡 The following code snippets are for explanatory purposes only. They are incomplete and should not be directly copied. Instead, follow the instructions to clone the GitHub and run the code from there.
For this particular tutorial, we will be using the example application in the examples/call-contract-with-tokens
folder.
cd examples/call-contract-with-tokens
This folder should contain two files, an index.js
and an DistributionExecutable.sol
.
Let’s take a look at our index.js
. This is the “frontend” to our dApp and contains two functions, a deploy
and a test
. Calling deploy
will deploy our backend solidity contract, DistributionExecutable.sol
on each chain, with the class initialized with values from a config file. We will do this later.
'use strict'; const { getDefaultProvider, Contract, constants: { AddressZero }, } = require('ethers'); const { utils: { deployContract }, } = require('@axelar-network/axelar-local-dev'); const DistributionExecutable = require('../../artifacts/examples/call-contract-with-token/DistributionExecutable.sol/DistributionExecutable.json'); const Gateway = require('../../artifacts/@axelar-network/axelar-cgp-solidity/contracts/interfaces/IAxelarGateway.sol/IAxelarGateway.json'); const IERC20 = require('../../artifacts/@axelar-network/axelar-cgp-solidity/contracts/interfaces/IERC20.sol/IERC20.json'); async function deploy(chain, wallet) { console.log(`Deploying DistributionExecutable for ${chain.name}.`); const contract = await deployContract(wallet, DistributionExecutable, [chain.gateway, chain.gasReceiver]); chain.distributionExecutable = contract.address; console.log(`Deployed DistributionExecutable for ${chain.name} at ${chain.distributionExecutable}.`); }
Now, let’s take a look at the test
function, which calls functions in the smart contract code. In your own app, you will probably have a real frontend, where interactions with the smart contract will be triggered by UI elements.
First, the test
function initializes the distributionExecutable
contract on each chain. (Each chain will have a distributionExecutable
running on it).
async function test(chains, wallet, options) { ... for (const chain of [source, destination]) { const provider = getDefaultProvider(chain.rpc); chain.wallet = wallet.connect(provider); chain.contract = new Contract(chain.distributionExecutable, DistributionExecutable.abi, chain.wallet); chain.gateway = new Contract(chain.gateway, Gateway.abi, chain.wallet); const usdcAddress = chain.gateway.tokenAddresses('aUSDC'); chain.usdc = new Contract(usdcAddress, IERC20.abi, chain.wallet); } ... }
The test
function takes in command-line parameters. The first parameter is chain A that you are calling the contract with tokens from. The second parameter is chain B that you are sending the message with tokens to.
async function test(chains, wallet, options) { const args = options.args || []; ... const source = chains.find((chain) => chain.name == (args[0] || 'Avalanche')); const destination = chains.find((chain) => chain.name == (args[1] || 'Fantom')); const amount = Math.floor(parseFloat(args[2])) * 1e6 || 10e6; const accounts = args.slice(3); ... }
An application that wants Axelar to automatically execute contract calls on chain B needs to pre-pay the gas cost. To calculate the estimated gas cost:
- Estimate the gas limit that the contract call will require on chain B.
gasLimit
- Query our API for the relative gas cost.
(getGasPrice)
- Calculate the amount of token to be paid as
gasLimit * gasPrice
async function test(chains, wallet, options) { ... const getGasPrice = options.getGasPrice; ... //Set the gasLimit to 3e6 (a safe overestimate) and get the gas price. const gasLimit = 3e6; const gasPrice = await getGasPrice(source, destination, AddressZero); ... }
Finally, we call the sendToMany()
method on the DistributionExecutable
contract that has been deployed on chain A. This method allows us to execute a contract on and send tokens to multiple accounts simultaneously.
The sendToMany()
method takes in the name of chain B, the address of the contract on chain B, the accounts to send tokens to, the token symbol and token amount to be credited, and the gas amount calculated above:
async function test(chains, wallet, options) { ... await ( await source.contract.**sendToMany**(destination.name, destination.distributionExecutable, accounts, 'aUSDC', amount, { value: BigInt(Math.floor(gasLimit * gasPrice)), }) ).wait(); while (BigInt(await destination.usdc.balanceOf(accounts[0])) == balance) { await sleep(2000); } ... }
Now let’s hop over to DistributionExecutable.sol
to see what the smart contract is actually doing.
Axelar provides a relayer service IAxelarGasService
that provides execution of approved messages. You can import the service from the core Axelar Solidity libraries.
SPDX-License-Identifier: MIT pragma solidity 0.8.9; import { IAxelarExecutable } from '@axelar-network/axelar-cgp-solidity/contracts/interfaces/IAxelarExecutable.sol'; import { IERC20 } from '@axelar-network/axelar-cgp-solidity/contracts/interfaces/IERC20.sol'; import { IAxelarGasService } from '@axelar-network/axelar-cgp-solidity/contracts/interfaces/IAxelarGasService.sol'; contract DistributionExecutable is IAxelarExecutable { IAxelarGasService gasReceiver; constructor(address _gateway, address _gasReceiver) IAxelarExecutable(_gateway) { gasReceiver = IAxelarGasService(_gasReceiver); } ... }
The IAxelarGasService
can receive gas via a few different methods, but in this example application, we will be paying for the gas with chain A’s own token by calling the payNativeGasForContractCall
method on the service. Once the gas has been paid, we can make a matching call to the Axelar Gateway service deployed on chain A.
function sendToMany( ... ) external payable { address tokenAddress = gateway.tokenAddresses(symbol); IERC20(tokenAddress).transferFrom(msg.sender, address(this), amount); IERC20(tokenAddress).approve(address(gateway), amount); bytes memory payload = abi.encode(destinationAddresses); if (msg.value > 0) { gasReceiver.**payNativeGasForContractCallWithToken**{ value: msg.value }( ... ); } gateway.callContractWithToken(destinationChain, destinationAddress, payload, symbol, amount); }
To call chain B from chain A and send some tokens along the way, the user needs to call callContractWithToken
on the gateway of chain A, specifying:
- The destination chain: must be an EVM chain from Chain names
- The destination contract address: must implement the
IAxelarExecutable
Interface defined in IAxelarExecutable.sol - The payload
bytes
to pass to the destination contract. - The token's symbol to transfer: must be a supported asset [Mainnet | Testnet | Testnet-2 ].
- The amount of the token to transfer.
💡 To call a contract and send tokens, the user needs to specify the destination chain, the destination contract address, the payload bytes to be sent, the symbol of the token, and the amount.
Finally, IAxelarExecutable
has an _executeWithToken
function that will be triggered by the Axelar network after the callContractWithToken
function has been executed. In other words, when the contract on chain B is called via callContractWithToken
from chain A, the _executeWithToken
method on the contract on chain B runs.
executeWithToken() has the following signature:
function _executeWithToken( string memory sourceChain, string memory sourceAddress, bytes calldata payload, string memory tokenSymbol, uint256 amount ) internal virtual {}
You can write any custom logic inside the _executeWithToken
. In our _executeWithToken
function, we decode the intended recipients of the tokens, then transfer tokens to each of the recipients.
function _executeWithToken( string memory, string memory, bytes calldata payload, string memory tokenSymbol, uint256 amount ) internal override { address[] memory recipients = abi.decode(payload, (address[])); address tokenAddress = gateway.tokenAddresses(tokenSymbol); uint256 sentAmount = amount / recipients.length; for (uint256 i = 0; i < recipients.length; i++) { IERC20(tokenAddress).transfer(recipients[i], sentAmount); } }
We can also imagine, however, other scenarios where you might want to call a contract on another chain and transfer some tokens. For example, suppose you’re building a cross-chain NFT marketplace. You want to make it so that a buyer can buy an NFT for sale from any chain. Your _executeWithToken
function might in that case not only transfer the token to the destination address, but also call another contract method on the destination chain, say purchaseNFT(uint256 nftId)
.
To do something like this you would first want to encode the purchaseNFT
function signature string using abi.encode
along with any function parameter values. You also want to encode the destination address.
function purchaseNFT( ... ) external payable { address tokenAddress = gateway.tokenAddresses(symbol); IERC20(tokenAddress).transferFrom(msg.sender, address(this), amount); IERC20(tokenAddress).approve(address(gateway), amount); bytes memory payload = abi.encode("purchaseNFT(uint256)", 56, destinationAddress) if (msg.value > 0) { gasReceiver.**payNativeGasForContractCallWithToken**{ value: msg.value }( ... ); } gateway.callContractWithToken(destinationChain, destinationAddress, payload, symbol, amount); }
In _executeWithToken
you could then directly do address(this).call(payload)
to execute purchaseNFT(56)
before forwarding the tokens received to the NFT seller’s address.
function _executeWithToken( string memory, string memory, bytes calldata payload, string memory tokenSymbol, uint256 amount ) internal override { // call purchaseNFT(56) address(this).call(payload) (/*ignore method signature and argument*/, address seller) = abi.decode(payload, (string, uint256, address)); // get ERC-20 address from gateway address tokenAddress = gateway.tokenAddresses(tokenSymbol); // transfer received tokens to the recipient IERC20(tokenAddress).transfer(recipient, amount); }
5. Run the local Axelar node
Now that we understand the call-contract-with-token
code, let’s see that it works. First, spin up a local Axelar node.
node scripts/createLocal
Leave this node running on a separate terminal before deploying and testing the dApps.
6. Deploy the contract
Deploy the contract with the command below.
node scripts/deploy examples/call-contract-with-token local
You should see some printout like this that shows the DistributionExecutable
was deployed on each chain.
node scripts/deploy examples/call-contract-with-token local Deploying DistributionExecutable for Moonbeam. Deploying DistributionExecutable for Avalanche. Deploying DistributionExecutable for Fantom. Deploying DistributionExecutable for Ethereum. Deploying DistributionExecutable for Polygon. Deployed DistributionExecutable for Fantom at 0x775C53cd1F4c36ac74Cb4Aa1a3CA1508e9C4Bd24. Deployed DistributionExecutable for Ethereum at 0x775C53cd1F4c36ac74Cb4Aa1a3CA1508e9C4Bd24. Deployed DistributionExecutable for Avalanche at 0x775C53cd1F4c36ac74Cb4Aa1a3CA1508e9C4Bd24. Deployed DistributionExecutable for Moonbeam at 0x775C53cd1F4c36ac74Cb4Aa1a3CA1508e9C4Bd24. Deployed DistributionExecutable for Polygon at 0x775C5
7. Run the test
Finally, run test
(which calls ExecutableSample
). The command below sends 100 tokens from Moonbeam to the Ethereum address 0xBa86A5719722B02a5D5e388999C25f3333c7A9fb
.
node scripts/test examples/call-contract-with-token local "Moonbeam" "Ethereum" 100 0xBa86A5719722B02a5D5e388999C25f3333c7A9fb
You should see some printout like this that shows the message passing occurred successfully. (Axelar takes a transaction fee which accounts for the discrepancy between 99 and 100).
--- Initially --- 0xBa86A5719722B02a5D5e388999C25f3333c7A9fb has 100 aUSDC --- After --- 0xBa86A5719722B02a5D5e388999C25f3333c7A9fb has 199 aUSDC
To run the test on Axelar testnet rather than against a local node, you can use the following commands instead:
node scripts/deploy examples/call-contract-with-token testnet node scripts/test examples/call-contract-with-token testnet "Moonbeam" "Ethereum" 100 0xBa86A5719722B02a5D5e388999C25f3333c7A9fb