IntroductionCosmos GMPSolidity UtilitiesSandboxGlossary
InterchainTokenServiceInterchainTokenFactoryTokenManagerAxelarGateway AxelarGasServiceAxelarExecutableAxelarJS SDK
Crosschain Message FlowaxlUSDCSecurity OverviewInterchain Transaction DurationEVM Contract Governance

Link Custom Tokens Deployed Across Multiple Chains into Interchain Tokens

Custom ERC-20 tokens deployed on multiple chains with specific minting policies, ownership structures, rate limits, and other bespoke functionalities can be turned into Interchain Tokens through the Interchain Token Service.

In this tutorial, you will:

  • Link custom tokens deployed across multiple chains into Interchain Tokens
  • Deploy a simple ERC-20 token on the Fantom chain
  • Deploy a simple ERC-20 token on the Polygon chain
  • Deploy a token manager on both Fantom and Polygon
  • Transfer mint access to the Interchain Token Service address on both Fantom and Polygon
  • Transfer your token between Fantom and Polygon

Prerequisites

You will need:

  • A basic understanding of Solidity and JavaScript
  • wallet with FTM and MATIC funds for testing. If you don’t have these funds, you can get FTM from the Fantom faucet and MATIC from the Mumbai faucets (12).

Deploy an ERC-20 token on the Fantom and Polygon testnets

Deploy the following SimpleCustomToken on the Fantom and Polygon testnets.

This code utilizes OpenZeppelin’s libraries to create a custom ERC20 token with functionalities for minting, burning, and access control. The token includes a minter role, which enables designated addresses to mint or burn tokens. Additionally, it incorporates ERC20Permit for gasless transactions. The contract starts with a predefined supply of tokens minted to the deployer’s address and establishes roles for a default administrator and minter:


// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;

// Import OpenZeppelin contracts for ERC20 standard token implementations,
// burnable tokens, access control mechanisms, and permit functionality
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Burnable.sol";
import "@openzeppelin/contracts/access/AccessControl.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol";

contract SimpleCustomToken is ERC20, ERC20Burnable, AccessControl, ERC20Permit {

// Define a constant for the minter role using keccak256 to generate a unique hash
    bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");

    constructor(address defaultAdmin, address minter)
        ERC20("SimpleCustomToken", "SCT") // Set token name and symbol
        ERC20Permit("SimpleCustomToken") // Initialize ERC20Permit with the token name
    {
        // Mint an initial supply of tokens to the message sender
        _mint(msg.sender, 10000 * 10 ** decimals());


        // Grant the DEFAULT_ADMIN_ROLE to the specified defaultAdmin address
        _grantRole(DEFAULT_ADMIN_ROLE, defaultAdmin);


        // Grant the MINTER_ROLE to the specified minter address
        // Addresses with the minter role are allowed to mint new tokens
        _grantRole(MINTER_ROLE, minter);
    }

    // Mint new tokens. Only addresses with the MINTER_ROLE can call this function
    function mint(address to, uint256 amount) public onlyRole(MINTER_ROLE) {
        _mint(to, amount);
    }

    // Burn tokens from a specified account
    function burn(address account, uint256 amount) public onlyRole(MINTER_ROLE) {
        _burn(account, amount);
    }
}

Set up your development environment

Create and initialize a project

Open up your terminal and navigate to any directory of your choice. Run the following commands to create and initiate a project:

mkdir custom-interchain-token-project && cd custom-interchain-token-project
npm init -y

Install Hardhat and the AxelarJS SDK

Install Hardhat and the AxelarJS SDK with the following commands:

npm install --save-dev hardhat@2.18.1 dotenv@16.3.1
npm install @axelar-network/axelarjs-sdk@0.13.9 crypto@1.0.1 @nomicfoundation/hardhat-toolbox@2.0.2

Set up project ABIs

Next, set up the ABIs for the Interchain Token Service and the contract from the token you deployed.

Create a folder named utils. Inside the folder, create the following new files and add the respective ABIs:

  • Add the Interchain Token Service ABI to interchainTokenServiceABI.json.
  • Add your custom token ABI to customTokenABI**.**json. You can get this from FTMScan or PolygonScan with the address of your deployed token if your contract is verified. Otherwise, you can get it from the same service you deployed the SimpleCustomToken on.

Set up an RPC for the local chain

Back in the root directory, set up an RPC for the Fantom and Polygon testnet.

Create an .env file

To make sure you’re not accidentally publishing your private key, create an [.env file](https://blog.bitsrc.io/a-gentle-introduction-to-env-files-9ad424cc5ff4) to store it in:

touch .env

Add your private key to .env and hardhat.config.js

Export your private key and add it to the  .env file you just created:

PRIVATE_KEY= // Add your account private key here
💡

If you will push this project on GitHub, create a .gitignore file and include .env.

Then, create a file with the name hardhat.config.js and add the following code snippet:

require("@nomicfoundation/hardhat-toolbox");
require("dotenv").config({ path: ".env" });

const PRIVATE_KEY = process.env.PRIVATE_KEY;

/** @type import('hardhat/config').HardhatUserConfig */
module.exports = {
  solidity: "0.8.18",
  networks: {
    fantom: {
      url: "https://rpc.ankr.com/fantom_testnet",
      chainId: 4002,
      accounts: [PRIVATE_KEY],
    },
    polygon: {
      url: "https://rpc.ankr.com/polygon_mumbai",
      chainId: 80001,
      accounts: [PRIVATE_KEY],
    },
  },
};

Deploy token manager on the Fantom testnet

Now that you have set up an RPC for the Fantom and Polygon testnet, you can deploy a token manager on the Fantom testnet.

Create a customInterchainToken.js script

Create a new file named customInterchainToken.js and import the required dependencies:

  • Ethers.js
  • The AxelarJS SDK
  • The custom token contract ABI
  • The address of the InterchainTokenService contract
  • The address of your token deployed on Fantom and Polygon testnet
const hre = require("hardhat");
const crypto = require("crypto");
const ethers = hre.ethers;
const {
  AxelarQueryAPI,
  Environment,
  EvmChain,
  GasToken,
} = require("@axelar-network/axelarjs-sdk");

const interchainTokenServiceContractABI = require("./utils/interchainTokenServiceABI");
const customTokenABI = require("./utils/customTokenABI");

const MINT_BURN = 0;
const LOCK_UNLOCK = 2;

const interchainTokenServiceContractAddress =
  "0xB5FB4BE02232B1bBA4dC8f81dc24C26980dE9e3C";

const fantomCustomTokenAddress = "0x6B9ee4Ae432ffE426491FeA0032aE350FD9fFE85"; // Replace with your token address on fantom
const polygonCustomTokenAddress = "0xe5f05c39BFf8efb465b573fD53D5556667743046"; // Replace with your token address on Polygon

Get the signer

Next, create a getSigner() function in customInterchainToken.js. This will obtain a signer for a secure transaction:

//...

async function getSigner() {
  const [signer] = await ethers.getSigners();
  return signer;
}

Get the contract instance

Then, create a getContractInstance() function in customInterchainToken.js . This will get the contract instance based on the contract’s address, ABI, and signer:

//...

async function getContractInstance(contractAddress, contractABI, signer) {
  return new ethers.Contract(contractAddress, contractABI, signer);
}

Deploy a token manager on Fantom

Create a deployTokenManager() function for the Fantom testnet. This will deploy a token manager with your custom token address:

//...

// Deploy token manager : Fantom
async function deployTokenManager() {
  // Get a signer to sign the transaction
  const signer = await getSigner();

  // Get the InterchainTokenService contract instance
  const interchainTokenServiceContract = await getContractInstance(
    interchainTokenServiceContractAddress,
    interchainTokenServiceContractABI,
    signer
  );

  // Generate a random salt
  const salt = "0x" + crypto.randomBytes(32).toString("hex");

  // Create params
  const params = ethers.utils.defaultAbiCoder.encode(
    ["bytes", "address"],
    [signer.address, fantomCustomTokenAddress]
  );

  // Deploy the token manager
  const deployTxData = await interchainTokenServiceContract.deployTokenManager(
    salt,
    "",
    MINT_BURN,
    params,
    ethers.utils.parseEther("0.01")
  );

  // Get the tokenId
  const tokenId = await interchainTokenServiceContract.interchainTokenId(
    signer.address,
    salt
  );

  // Get the token manager address
  const expectedTokenManagerAddress =
    await interchainTokenServiceContract.tokenManagerAddress(tokenId);

  console.log(
    `
	Salt: ${salt},
	Transaction Hash: ${deployTxData.hash},
	Token ID: ${tokenId},
	Expected token manager address: ${expectedTokenManagerAddress},
	`
  );
}

Add a main() function

Add a main() function to execute the customInterchainToken.js script and handle any errors that may arise:

//...

async function main() {
  const functionName = process.env.FUNCTION_NAME;
  switch (functionName) {
    case "deployTokenManager":
      await deployTokenManager();
      break;
    default:
      console.error(`Unknown function: ${functionName}`);
      process.exitCode = 1;
      return;
  }
}

main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

Run the customInterchainToken.js script to deploy to Fantom

Run the script in your terminal to register and deploy the token, specifying the fantom testnet:

FUNCTION_NAME=deployTokenManager npx hardhat run customInterchainToken.js --network fantom

If you see something similar to the following on your console, you have successfully registered your token as a canonical Interchain Token.

Salt: 0x368f6c0d56af7cb3d90aac4b12186cbb0639edf7923f1405adf752bace5ed795,
Transaction Hash: 0x1854ed395fd1a6542433e277c607f4817ef896ef9c841e2ceaca108d08ff997c,
Token ID: 0xf60cd19fca372e0351a2619fe63435203a8699105c84850681b4aabbd3d9f6e0,
Expected token manager address: 0x71D6374761Dcb3e331Ee08051998A0A958438571,

Store the token ID and salt value

Copy the token ID and salt value and store them somewhere safe. You will need them to initiate a remote token manager deployment and token transfer in a later step.

Check the transaction on the Fantom testnet scanner

Check the Fantom testnet scanner to see if you have successfully deployed a token manager.

Remotely deploy a token manager on the Polygon testnet

You’ve just successfully deployed a token manager to Fantom, which you are using as your local chain. Now, deploy a token manager remotely to Polygon, which will act as the remote chain in this tutorial. Remember that you can specify any two chains to be your local and remote chains.

Estimate gas fees

In customInterchainToken.js, call estimateGasFee() from the AxelarJS SDK to estimate the actual cost of deploying your remote Canonical Interchain Token on a remote chain:

//...

const api = new AxelarQueryAPI({ environment: Environment.TESTNET });

// Estimate gas costs
async function gasEstimator() {
  const gas = await api.estimateGasFee(
    EvmChain.FANTOM,
    EvmChain.POLYGON,
    GasToken.FTM,
    700000,
    1.1
  );

  return gas;
}

//...

Perform remote token manager deployment

Create a deployRemoteTokenManager() function. This will deploy the remote Canonical Interchain Token on the Polygon Mumbai testnet. Make sure to change the salts to the value you saved from a previous step.

//...

// Deploy remote token manager : Polygon
async function deployRemoteTokenManager() {
  // Get a signer to sign the transaction
  const signer = await getSigner();

  // Get the InterchainTokenService contract instance
  const interchainTokenServiceContract = await getContractInstance(
    interchainTokenServiceContractAddress,
    interchainTokenServiceContractABI,
    signer
  );

  // Create params
  const param = ethers.utils.defaultAbiCoder.encode(
    ["bytes", "address"],
    [signer.address, polygonCustomTokenAddress]
  );

  const gasAmount = await gasEstimator();

  // Deploy the token manager
  const deployTxData = await interchainTokenServiceContract.deployTokenManager(
    "0x368f6c0d56af7cb3d90aac4b12186cbb0639edf7923f1405adf752bace5ed795", // change salt
    "Polygon",
    MINT_BURN,
    param,
    ethers.utils.parseEther("0.01"),
    { value: gasAmount }
  );

  // Get the tokenId
  const tokenId = await interchainTokenServiceContract.interchainTokenId(
    signer.address,
    "0x368f6c0d56af7cb3d90aac4b12186cbb0639edf7923f1405adf752bace5ed795" // change salt
  );

  // Get the token manager address
  const expectedTokenManagerAddress =
    await interchainTokenServiceContract.tokenManagerAddress(tokenId);

  console.log(
    `
	Transaction Hash: ${deployTxData.hash},
	Token ID: ${tokenId},
	Expected token manager address: ${expectedTokenManagerAddress},
	`
  );
}

Update main() to deploy to remote chains

Update main() to execute deployRemoteTokenManager() :

//...

async function main() {
  const functionName = process.env.FUNCTION_NAME;
  switch (functionName) {
    //...
    case "deployRemoteTokenManager":
      await deployRemoteTokenManager();
      break;
    default:
    //...
  }
}

//...

Run the customInterchainToken.js script to deploy to Polygon

Run the script in your terminal to deploy a token manager remotely, once again specifying the fantom testnet (the source chain where all transactions are taking place):

FUNCTION_NAME=deployRemoteTokenManager npx hardhat run customInterchainToken.js --network fantom

You should see something similar to the following on your console:

Transaction Hash: 0xbfe92d3fb5bf3e9e08f65a3e11f231ff9df9c65985d2d5b952acf82e1375d5a5,
Token ID: 0xf60cd19fca372e0351a2619fe63435203a8699105c84850681b4aabbd3d9f6e0,
Expected token manager address: 0x71D6374761Dcb3e331Ee08051998A0A958438571,

Take a look at the token ID and the expected token manager address. These must match the ones obtained in the previous step, as they are linked with the same salt value when deploying a token manager remotely on the Polygon testnet.

💡

When deploying the token manager to a preferred chain other than the local chain (in our example, the Fantom testnet) while the remote chain is Polygon, make sure to deploy the remote token manager from the local chain. If you deploy to the Polygon testnet, you may encounter a TokenManagerDoesNotExist error. This error occurs because there is no token manager on Polygon with the tokenId you are using. You don’t need to deploy a remote token manager for Polygon because there is already one available on Fantom. Therefore, we deploy that token manager from the Fantom testnet.

Check the transaction on the Axelar testnet scanner

Check the Axelarscan testnet scanner to see if you have successfully deployed the remote token manager on the Polygon Mumbai testnet. It should look something like this.

Add gas if needed. Ensure that Axelar shows a successful transaction before continuing to the next step.

Transfer mint access to the Interchain Token Service address on the Fantom and Polygon testnets

You must transfer mint access to the Interchain Token Service address on both chains before you can mint and burn tokens while moving assets between chains.

Transfer mint access to the Interchain Token Service on the Fantom testnet

Create a transferMintAccessToITSOnFantom() function that will perform the mint access transfer on the Fantom testnet.

//...

// Transfer mint access on all chains to ITS : Fantom
async function transferMintAccessToITSOnFantom() {
  // Get a signer to sign the transaction
  const signer = await getSigner();

  const tokenContract = await getContractInstance(
    fantomCustomTokenAddress,
    customTokenABI,
    signer
  );

  // Get the minter role
  const getMinterRole = await tokenContract.MINTER_ROLE();

  const grantRoleTxn = await tokenContract.grantRole(
    getMinterRole,
    interchainTokenServiceContractAddress
  );

  console.log("grantRoleTxn: ", grantRoleTxn.hash);
}

Update main() to transfer mint access on Fantom testnet

Update main() to execute transferMintAccessToITSOnFantom() :

//...

async function main() {
  const functionName = process.env.FUNCTION_NAME;
  switch (functionName) {
    //...
    case "transferMintAccessToITSOnFantom":
      await transferMintAccessToITSOnFantom();
      break;
    default:
    //...
  }
}

//...

Run the customInterchainToken.js script to deploy to Fantom testnet

Run the script in your terminal to transfer mint access to ITS specifying the fantom testnet:

FUNCTION_NAME=transferMintAccessToITSOnFantom npx hardhat run customInterchainToken.js --network fantom

You should see something similar to the following on your console:

grantRoleTxn:  0x12ff55a84aa3bb7bf6a1dada8f14017a0a610bccd9199ca108f22aafdd16a7f2

Check the transaction on the Fantom testnet scanner

Check the Fantom testnet scanner to see if you have successfully transferred mint access to the Interchain Token Service address.

Transfer mint access to the Interchain Token Service on the Polygon testnet

Create a transferMintAccessToITSOnPolygon() function that will perform the mint access transfer on the Fantom testnet.

//...

// Transfer mint access on all chains to ITS : Polygon
async function transferMintAccessToITSOnPolygon() {
  // Get a signer to sign the transaction
  const signer = await getSigner();

  const tokenContract = await getContractInstance(
    polygonCustomTokenAddress,
    customTokenABI,
    signer
  );

  // Get the minter role
  const getMinterRole = await tokenContract.MINTER_ROLE();

  const grantRoleTxn = await tokenContract.grantRole(
    getMinterRole,
    interchainTokenServiceContractAddress
  );

  console.log("grantRoleTxn: ", grantRoleTxn.hash);
}

//...

Update main() to transfer mint access on Fantom testnet

Update main() to execute transferMintAccessToITSOnPolygon() :

//...

async function main() {
  const functionName = process.env.FUNCTION_NAME;
  switch (functionName) {
    //...
    case "transferMintAccessToITSOnPolygon":
      await transferMintAccessToITSOnPolygon();
      break;
    default:
    //...
  }
}

//...

Run the customInterchainToken.js script to deploy to the Polygon testnet

Run the script in your terminal to transfer mint access to ITS, specifying the polygon testnet:

FUNCTION_NAME=transferMintAccessToITSOnPolygon npx hardhat run customInterchainToken.js --network polygon

You should see something similar to the following on your console:

grantRoleTxn:  0x2e581188fc57a97d2ab040de9440c112eb9a5553b43646a64a4fa43d8e79991d

Check the transaction on the Polygon testnet scanner

Check the Polygon testnet scanner to see if you have successfully transferred Mint access to the Interchain Token Service address.

Transfer your token between chains

Now that you have deployed a TokenManager on both Fantom and Polygon testnet, you can transfer your token between those two chains using the [interchainTransfer()](https://github.com/axelarnetwork/interchain-token-service/blob/9edc4318ac1c17231e65886eea72c0f55469d7e5/contracts/interfaces/IInterchainTokenStandard.sol#L19) method.

Initiate a remote token transfer

In customInterchainToken.js, create a transferTokens() function that will facilitate remote token transfers between chains. Change the token ID to the tokenId that you saved from an earlier step, and change the address in transfer to your own wallet address:

//...

// Transfer tokens : Fantom -> Polygon
async function transferTokens() {
  // Get a signer to sign the transaction
  const signer = await getSigner();

  const interchainTokenServiceContract = await getContractInstance(
    interchainTokenServiceContractAddress,
    interchainTokenServiceContractABI,
    signer
  );
  const gasAmount = await gasEstimator();
  const transfer = await interchainTokenServiceContract.interchainTransfer(
    "0xf60cd19fca372e0351a2619fe63435203a8699105c84850681b4aabbd3d9f6e0", // tokenId, the one you store in the earlier step
    "Polygon",
    "0x510e5EA32386B7C48C4DEEAC80e86859b5e2416C", // receiver address
    ethers.utils.parseEther("50"), // amount of token to transfer
    "0x",
    ethers.utils.parseEther("0.01"), // gasValue
    {
      // Transaction options should be passed here as an object
      value: gasAmount,
    }
  );

  console.log("Transfer Transaction Hash:", transfer.hash);
  // 0xc2bb491cb1987e3ae9c164694b0c84a649c79d615c8c51c5c5df2bb339ee1f07
}

Update main() to execute token transfer

Update the main() to execute transferTokens():

//...

async function main() {
  const functionName = process.env.FUNCTION_NAME;
  switch (functionName) {
    //...
    case "transferTokens":
      await transferTokens();
      break;
    default:
    //...
  }
}

//...

Run the customInterchainToken.js script to transfer tokens

Run the script in your terminal, specifying the fantom testnet:

FUNCTION_NAME=transferTokens npx hardhat run customInterchainToken.js --network fantom

You should see something similar to the following on your console:

Transfer Transaction Hash: 0x146589f59683efd6d79cd1404afab6b6df9d052d55cbf69d29fc73c170b73e8b

If you see this, it means that your interchain transfer has been successful! 🎉

Check the transaction on the Axelar testnet scanner

Check the Axelarscan testnet scanner to see if you have successfully transferred SCT from the Fantom testnet to the Polygon testnet. It should look something like this.

Congratulations!

You have now programmatically linked custom tokens deployed on multiple chains as Interchain Toke using Axelar’s Interchain Token Service and transferred it between two chains.

Great job making it this far! To show your support to the author of this tutorial, please post about your experience and tag @axelarnetwork on Twitter (X).

What’s next

You can also explore other functionalities of the Interchain Token Service, such as:

References

Edit this page