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 Token Manage Address on both Fantom and Polygon
  • Transfer your token between Fantom and Polygon

You will need:

  • A basic understanding of Solidity and JavaScript
  • A 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 Polygon faucets (1, 2).

Deploy the following SimpleCustomToken on the Fantom and Polygon testnets.

This code utilizes OpenZeppelin’s libraries to create a custom ERC-20 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 ERC-20 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);
}
}

To learn how to deploy your own ERC-20 token using Hardhat step by step, check out this Hardhat guide.

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

Terminal window
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:

Terminal window
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

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.

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

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:

Terminal window
touch .env

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

Terminal window
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://polygon-amoy.drpc.org ",
chainId: 80001,
accounts: [PRIVATE_KEY],
},
},
};

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 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 = 4;
const interchainTokenServiceContractAddress =
"0xB5FB4BE02232B1bBA4dC8f81dc24C26980dE9e3C";
const fantomCustomTokenAddress = "0x8D4a6B2A784749BBc412A41C1440C5A67EAB57EE"; // Replace with your token address on fantom
const polygonCustomTokenAddress = "0x7884ac325fa7aedB8A4d7bBD92671e8699f49108"; // Replace with your token address on Polygon

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;
}

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);
}

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 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 script in your terminal to register and deploy the token, specifying the fantom testnet:

Terminal window
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.

Terminal window
Salt: 0x7f5fe989334ad23b77e8da841f672394d021ae83ff0a24afb461ba19d72e3553,
Transaction Hash: 0x026ee7992de2108ecb83f37119ec84ebed371ff724d38e8fd055cbecde5b77e6,
Token ID: 0x11780bda6e8238e24cdcd88e9b0088f1ed354c8b03c836f35f9ddb9e2c8be7c5,
Expected Token Manager Address: 0xad8Cf8FF7BBe82269844bBcbA60bAa8f02781f13,

Copy the token ID, Expected Token Manager, 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 Fantom testnet scanner to see if you have successfully deployed a token manager.

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 as your local and remote chains.

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;
}
//...

Create a deployRemoteTokenManager() function. This will deploy the remote Token Manager on the Polygon 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(
"0x7f5fe989334ad23b77e8da841f672394d021ae83ff0a24afb461ba19d72e3553", // change salt
"Polygon",
MINT_BURN,
param,
ethers.utils.parseEther("0.01"),
{ value: gasAmount },
);
// Get the tokenId
const tokenId = await interchainTokenServiceContract.interchainTokenId(
signer.address,
"0x7f5fe989334ad23b77e8da841f672394d021ae83ff0a24afb461ba19d72e3553", // 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 execute deployRemoteTokenManager() :

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

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:

Terminal window
Transaction Hash: 0x5dbba37f7741d19ca872bb4b2a29523baa002a3d6f0f31834bc34d6d6ca633f5,
Token ID: 0x11780bda6e8238e24cdcd88e9b0088f1ed354c8b03c836f35f9ddb9e2c8be7c5,
Expected Token Manager Address: 0xad8Cf8FF7BBe82269844bBcbA60bAa8f02781f13,

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 Axelarscan testnet scanner to see if you have successfully deployed the remote token manager on the Polygon testnet. It should look something like this.

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

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

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

//...
// Transfer mint access on all chains to the Expected Token Manager : Fantom
async function transferMintAccessToTokenManagerOnFantom() {
// 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,
"0xad8Cf8FF7BBe82269844bBcbA60bAa8f02781f13",
);
console.log("grantRoleTxn: ", grantRoleTxn.hash);
}

Update main() to execute transferMintAccessToTokenManagerOnFantom() :

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

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

Terminal window
FUNCTION_NAME=transferMintAccessToTokenManagerOnFantom npx hardhat run customInterchainToken.js --network fantom

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

Terminal window
grantRoleTxn: 0xec32651883a4d48f76e957f7fe6ca39aaa616cfb5eba89d6ee4a73f768874222

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

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

//...
// Transfer mint access on all chains to the Expected Token Manager Address : Polygon
async function transferMintAccessToTokenManagerOnPolygon() {
// 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,
"0xad8Cf8FF7BBe82269844bBcbA60bAa8f02781f13",
);
console.log("grantRoleTxn: ", grantRoleTxn.hash);
}
//...

Update main() to execute transferMintAccessToTokenManagerOnPolygon() :

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

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

Terminal window
FUNCTION_NAME=transferMintAccessToTokenManagerOnPolygon npx hardhat run customInterchainToken.js --network polygon

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

Terminal window
grantRoleTxn: 0xb3d4c9264faabe137595a9032b3958a07d24c308df5c8c3e45d711d7b9df4488

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

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.

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](### Store the token ID, Expected Token Manager, and salt value), 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(
"0x11780bda6e8238e24cdcd88e9b0088f1ed354c8b03c836f35f9ddb9e2c8be7c5", // tokenId, the one you store in the earlier step
"Polygon",
"0x510e5EA32386B7C48C4DEEAC80e86859b5e2416C", // receiver address
ethers.utils.parseEther("500"), // 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);
// 0x65258117e8133397b047a6192cf69a1b48f59b0cb806be1c0fa5a7c1efd747ef
}

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 script in your terminal, specifying the fantom testnet:

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

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

Terminal window
Transfer Transaction Hash: 0x65258117e8133397b047a6192cf69a1b48f59b0cb806be1c0fa5a7c1efd747ef

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

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.

You have now programmatically linked custom tokens deployed on multiple chains as Interchain Token using Axelar’s Interchain Token Service and transfer 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).

For further examples utilizing the Interchain Token Service, check out the following in the axelar-examples repo on GitHub:

  • its-custom-token — Demonstrates how to use the ITS with a custom token implementation.
  • its-interchain-token — Demonstrates how to deploy Interchain Tokens that are connected across EVM chains and how to send some tokens across.
  • its-canonical-token - Demonstrates how to deploy canonical Interchain Token and send cross-chain transfer for these tokens.
  • its-lock-unlock-fee Demonstrates how to deploy deploy interchain token with lock/unlock_fee token manager type.
  • its-executable Demonstrates how to deploy interchain token and send a cross-chain transfer along with a message.
  • its-mint-burn-from Demonstrates how to deploy interchain token with uses burnFrom() on token being bridged rather than burn().

Edit on GitHub