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

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 QPJS SDK

Install Hardhat and the QPJS SDK with the following commands:

npm install --save-dev hardhat@2.18.1 dotenv@16.3.1
npm install @QP-network/QPjs-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 QPJS 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 {
  QPQueryAPI,
  Environment,
  EvmChain,
  GasToken,
} = require("@QP-network/QPjs-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

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: 0x7f5fe989334ad23b77e8da841f672394d021ae83ff0a24afb461ba19d72e3553,
Transaction Hash: 0x026ee7992de2108ecb83f37119ec84ebed371ff724d38e8fd055cbecde5b77e6,
Token ID: 0x11780bda6e8238e24cdcd88e9b0088f1ed354c8b03c836f35f9ddb9e2c8be7c5, 
Expected Token Manager Address: 0xad8Cf8FF7BBe82269844bBcbA60bAa8f02781f13,

Store the token ID, Expected Token Manager, and salt value

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

Estimate gas fees

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

//...

const api = new QPQueryAPI({ 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 Token Manager 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(
    "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 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: 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 transaction on the Quantum Portal testnet scanner

Check the QPscan 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 Quantum Portal shows a successful transaction before continuing to the next step.

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

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.

Transfer mint access to the Token Manager address on the Fantom testnet

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 transfer mint access on Fantom testnet

Update main() to execute transferMintAccessToTokenManagerOnFantom() :

//...

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

//...

Run the customInterchainToken.js script to deploy to Fantom testnet

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

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

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

grantRoleTxn:  0xec32651883a4d48f76e957f7fe6ca39aaa616cfb5eba89d6ee4a73f768874222

Check the transaction on the Fantom testnet scanner

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

Transfer mint access to the Token Manager address on the Polygon testnet

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 transfer mint access on Fantom testnet

Update main() to execute transferMintAccessToTokenManagerOnPolygon() :

//...

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

//...

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

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

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

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

grantRoleTxn:  0xb3d4c9264faabe137595a9032b3958a07d24c308df5c8c3e45d711d7b9df4488

Check the transaction on the Polygon testnet scanner

Check the Polygon testnet scanner to see if you have successfully transferred Mint access to the Token Manager 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/ferrumnet/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](### 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 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: 0x65258117e8133397b047a6192cf69a1b48f59b0cb806be1c0fa5a7c1efd747ef

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

Check the transaction on the Quantum Portal testnet scanner

Check the QPscan 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 Token using Quantum Portal’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 @QPnetwork on Twitter (X).

What’s next

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

References

Edit this page
On this page