Programmatically Create a Canonical Interchain Token Using the Interchain Token Service

If you have an ERC-20 token on one or more blockchains and you want to make the token interoperable across chains, the Interchain Token Service provides a solution. You can transform an ERC-20 token into an Interchain Token by deploying a token manager.

If you would like to create a wrapped, bridgeable version of your ERC-20 token on other chains, you can register it as a Canonical Interchain Token using the InterchainTokenFactory contract.

Each token can only be registered once as a Canonical Interchain Token. This ensures unique and streamlined token management across different blockchains. Though you can register your Canonical Interchain Token directly through the Interchain Token Portal, there are times where you may want to do so programmatically, such as when you have already deployed a token on one chain and wish to deploy a wrapped version of that token on another chain.

In this tutorial, you will learn how to:

  • Programmatically create a Canonical Interchain Token from scratch using Axelar’s Interchain Token Service
  • Register a Canonical Interchain Token on the Fantom chain
  • Deploy remote Canonical Interchain Token on the Polygon chain
  • 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 Amoy faucets (1, 2).

Create a Simple ERC-20 token and give it a name and symbol. You can skip this step if you already have an ERC-20 token deployed on the Fantom testnet.

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 canonical-interchain-token-project && cd canonical-interchain-token-project
npm init -y

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, Interchain Token Factory, 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:

Back in the root directory, set up an RPC for the Fantom testnet. You will use this as your local (source) chain.

To make sure you’re not accidentally publishing your private key, create an .env file 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],
},
},
};

Now that you have set up an RPC for the Fantom testnet, you can register a Canonical Interchain Token.

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

const hre = require("hardhat");
const crypto = require("crypto");
const {
AxelarQueryAPI,
Environment,
EvmChain,
GasToken,
} = require("@axelar-network/axelarjs-sdk");
const interchainTokenServiceContractABI = require("./utils/interchainTokenServiceABI");
const interchainTokenFactoryContractABI = require("./utils/interchainTokenFactoryABI");
const customTokenContractABI = require("./utils/customTokenABI");
const MINT_BURN = 0;
const LOCK_UNLOCK = 2;
// Addresses on mainnet/testnet
const interchainTokenServiceContractAddress =
"0xB5FB4BE02232B1bBA4dC8f81dc24C26980dE9e3C";
const interchainTokenFactoryContractAddress =
"0x83a93500d23Fbc3e82B410aD07A6a9F7A0670D66";
const customTokenAddress = "0x0EF6280417A1BF22c8fF05b54D7A7928a173E605"; // your token address

Next, create a getSigner() function in canonicalInterchainToken.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 canonicalInterchainToken.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);
}

Now you’re ready to register your token as a Canonical Interchain Token! Create a registerCanonicalInterchainToken() function for the Fantom testnet. This will register a Canonical Interchain Token with your custom token address:

// Register Canonical Interchain Token to the Fantom chain.
async function registerCanonicalInterchainToken() {
// Get a signer to sign the transaction
const signer = await getSigner();
// Create contract instances
const interchainTokenFactoryContract = await getContractInstance(
interchainTokenFactoryContractAddress,
interchainTokenFactoryContractABI,
signer,
);
const interchainTokenServiceContract = await getContractInstance(
interchainTokenServiceContractAddress,
interchainTokenServiceContractABI,
signer,
);
// Register a new Canonical Interchain Token
const deployTxData =
await interchainTokenFactoryContract.registerCanonicalInterchainToken(
customTokenAddress, // Your token address
);
// Retrieve the token ID of the newly registered token
const tokenId =
await interchainTokenFactoryContract.canonicalInterchainTokenId(
customTokenAddress,
);
const expectedTokenManagerAddress =
await interchainTokenServiceContract.tokenManagerAddress(tokenId);
console.log(
`
Transaction Hash: ${deployTxData.hash},
Token ID: ${tokenId},
Expected Token Manager Address: ${expectedTokenManagerAddress},
`,
);
}

Add a main() function to execute the canonicalInterchainToken.js script. It will handle any errors that may arise:

//...
async function main() {
const functionName = process.env.FUNCTION_NAME;
switch (functionName) {
case "registerCanonicalInterchainToken":
await registerCanonicalInterchainToken();
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=registerCanonicalInterchainToken npx hardhat run canonicalInterchainToken.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
Transaction Hash: 0x551cdba803a55bc8989a8eb74165c03563590f8f0161b0eb1308ae3953295de8,
Token ID: 0x1fe2d9bf8d6d7288224e83399696bcc7ac60421723e6150f8de800e8b1015a7a,
Expected Token Manager Address: 0x1d680d2B53aEf3E0aDdC7544105A9ae6091bA793,

Copy the token ID and store it somewhere safe. You will need it to initiate a remote token transfer in a later step.

Check the Fantom testnet scanner to see if you have successfully registered your token as a Canonical Interchain Token.

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

In canonicalInterchainToken.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 deployRemoteCanonicalInterchainToken() function that will perform token remote canonical deployment on the Polygon Amoy testnet.

//...
// deployRemoteCanonicalInterchainToken: On Polygon
async function deployRemoteCanonicalInterchainToken() {
// Get a signer for authorizing transactions
const signer = await getSigner();
// Get contract for remote deployment
const interchainTokenFactoryContract = await getContractInstance(
interchainTokenFactoryContractAddress,
interchainTokenFactoryContractABI,
signer,
);
// Estimate gas fees
const gasAmount = await gasEstimator();
// Initiate transaction
const txn =
await interchainTokenFactoryContract.deployRemoteCanonicalInterchainToken(
"Fantom",
customTokenAddress, // Your token address
"Polygon",
gasAmount,
{ value: gasAmount },
);
console.log(`Transaction Hash: ${txn.hash}`);
}
//...

Update main() to execute deployRemoteCanonicalInterchainToken() :

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

Run the script in your terminal to to deploy remote Canonical Interchain Token, once again specifying the fantom testnet (the source chain where all transactions are taking place):

Terminal window
FUNCTION_NAME=deployRemoteCanonicalInterchainToken npx hardhat run canonicalInterchainToken.js --network fantom

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

Terminal window
Transaction Hash: 0xb963f5ce3402e1787ead0c8f421be79ac06e5b78305b1dbce184806e099d54fd

Check the Axelarscan testnet scanner to see if you have successfully deployed the remote Canonical Interchain Token “MAT” on the Polygon Amoy testnet. It should look something like this. Make sure that Axelar shows a successful transaction before continuing on to the next step.

Now that you have registered and deployed a Canonical Interchain Token both locally to Fantom and remotely to Polygon, you can transfer between those two chains via the interchainTransfer() method.

In canonicalInterchainToken.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 token between chains.
async function transferTokens() {
const signer = await getSigner();
const interchainTokenServiceContract = await getContractInstance(
interchainTokenServiceContractAddress,
interchainTokenServiceContractABI,
signer,
);
const customTokenContract = await getContractInstance(
customTokenAddress,
customTokenContractABI,
signer,
); // Approve ITS to spend tokens
await customTokenContract.approve(
interchainTokenServiceContractAddress,
ethers.utils.parseEther("50"),
);
const gasAmount = await gasEstimator();
// Send via token
const transfer = await interchainTokenServiceContract.interchainTransfer(
"0x1fe2d9bf8d6d7288224e83399696bcc7ac60421723e6150f8de800e8b1015a7a", // token ID from registerCanonicalInterchainToken
"Polygon", // Destination chain
"0x510e5EA32386B7C48C4DEEAC80e86859b5e2416C", // Destination address. Update with your own wallet address
ethers.utils.parseEther("50"), // Amount to transfer
"0x00",
gasAmount, // gasValue
{ value: gasAmount },
);
console.log("Transfer Transaction Hash:", transfer.hash);
}

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 canonicalInterchainToken.js --network fantom

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

Terminal window
Transfer Transaction Hash: 0x3f3632918d68e2129c08a354b619be29906423062e1c700c7bb32118231a20e9

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

💡

Note: If you get the following nonce too low error, wait a few minutes and run canonicalInterchainToken.js again. Some chains have a longer transaction time than others.

Terminal window
reason: 'nonce has already been used',
code: 'NONCE_EXPIRED',
error: ProviderError: nonce too low

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

You can also import the new token into your own wallet with its contract address that you saved from FTMScan.

You have now programmatically created a Canonical Interchain Token using Axelar’s Interchain Token Service and transferred it between two chains. You should now be able to confidently create and manage your own Interchain Tokens, opening up a wide range of possibilities for token transfers and asset bridges.

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