Building Multichain Stablecoins: Part One

Building Multichain Stablecoins: Part One

Deploy multichain stablecoins with Axelar's Interchain Token Service: custom ERC20 integrations, cross-chain transactions, and upgradable contracts.

As the web3 ecosystem continues to grow and more blockchains enter the industry, ensuring that your token is available on multiple chains is essential for the success of your application. What is also essential is that your token can be sent across these different chains to one another, rather than having siloed instances of your token across different chains. Axelar's Interchain Token Service is built to provide a solution for this.

This blog assumes you have an understanding of how to use Hardhat, a basic understanding of upgradable contracts, and how ERC20s operate.

Note: The token being built here contains many of the properties that can be used for a stablecoin such as transaction fee redistribution to token holders and transaction burning to contain inflation. The token will not contain all the components of a complete stablecoin. Rather the blog will focus more on the integration of the cross-chain components and testing of the token.

What is Interchain Token Service?

The Interchain Token Service (ITS) allows for the integration of tokens across many different blockchains. It can support the deployment of fresh new Interchain Tokens across multiple chains and it can also connect pre-existing custom ERC20s. ITS also comes with an easy-to-use frontend, which offers a no-code solution for deploying your token across any connected chain that you choose.

Objective

In this blog, you will be focusing on custom ERC20 token integrations to ITS. Custom tokens can have more advanced functionality than the InterchainTokens that ITS deploys out of the box in its no-code solution. The token that you will be building will be an upgradable token that will be deployed using Axelar's create3 service. The token will also have transaction fee redistribution to token holders and a burning mechanism, that burns a small percentage of each transaction to simulate inflation control.

In addition to the token itself, you will be using a TokenFactory contract that you will build out to deploy your token across different chains. There will be two types of tokens that will be built out, NativeToken and SemiNativeToken. The NativeToken will be the main token with the previously discussed features. The SemiNativeToken on the other hand will be a simpler ERC20 that will also be deployed through the factory. The reason for the two different tokens is to simulate a common issue, which teams in the stablecoin space face. Oftentimes, teams may be restricted to only officially operate on a certain group of chains, perhaps due to regulations or other restrictions. For the chains, that a team is regulated to operate on they can use the NativeToken but for those chains where they are not regulated, the simpler SemiNativeToken can be deployed by anyone via the TokenFactory. The SemiNativeToken will eventually be replaced by a NativeToken once the team is eligible to fully operate on that chain.

Architecture

This project will be built using Hardhat (but it can of course be built out with Foundry as well). The five contracts you'll be interacting with are the TokenFactory, TokenDeployer, NativeToken, SemiNativeToken, and AccessControl.

Let's build!

Start by cloning the starter code.

NativeToken

You can begin by writing up the NativeToken contract.

Start by giving the token a name and importing the required OpenZeppelin helper contracts.

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

import '@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol';
import '@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20BurnableUpgradeable.sol';
import '@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20PausableUpgradeable.sol';
import '@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20PermitUpgradeable.sol';
import '@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol';

contract NativeToken is  Initializable, ERC20Upgradeable, ERC20BurnableUpgradeable, ERC20PausableUpgradeable, ERC20PermitUpgradeable {}

Next, go ahead and create the initializer for the token.

 /// @custom:oz-upgrades-unsafe-allow constructor
    constructor() {
        _disableInitializers();
    }

    function initialize() public initializer {
        __ERC20_init('USD Token', 'USD');
        __ERC20Burnable_init();
        __ERC20Pausable_init();
        __ERC20Permit_init('USD Token');
    }

Once the initializer has been implemented you can implement two publicly available mint() and burn() functions.

 function mint(address _to, uint256 _amount) public whenNotPaused isBlacklisted(_to) {
     _mint(_to, _amount);
 }

 function burn(address _from, uint256 _amount) public whenNotPaused {
     _burn(_from, _amount);
 }

Note the whenNotPaused modifier on both the mint() and burn() functions which will be activated in case of an emergency.

You also need to override the _update() function. This function is written out in the ERC20Upgradeable contract that you're inheriting from. The _update() function is used when transferring a value amount of tokens from one address to another. You will need to eventually implement the custom burning and transaction fee logic in this function.

For now, implement it as follows:

    function _update(address from, address to, uint256 value)
        internal
        override(ERC20Upgradeable, ERC20PausableUpgradeable) whenNotPaused
    {
        ERC20Upgradeable._update(from, to, value);
    }

Great! At this point, you should have a working ERC20 token that can be upgradable!

Let's build this out a bit further. As mentioned before we want the token to have a burnRate for every transaction that is sent and a fee which will be paid out to token holders.

Set the burnRate and txFeeRate in storage as two public uint256 variables. You can set a value for them in your initialize() function

Your contract should now look like this.

    uint256 public s_burnRate;
    uint256 public s_txFeeRate;


    /*************\
     INITIALIZATION
    /*************/

    /// @custom:oz-upgrades-unsafe-allow constructor
    constructor() {
        _disableInitializers();
    }

    function initialize(
        uint256 _burnRate,
        uint256 _txFeeRate
    ) public initializer {
        __ERC20_init('USD Token', 'USD');
        __ERC20Burnable_init();
        __ERC20Pausable_init();
        __ERC20Permit_init('USD Token USD');

        s_burnRate = _burnRate;
        s_txFeeRate = _txFeeRate;
    }

Now, back in your _update() function you can include some logic that will occur on every token transfer.

First, you set the burnAmount by multiplying the value of the transfer by the s_burnRate you set and then divide that by 1e18 (assuming your token has 18 decimal points). The same can be done for the transaction fee.

Now that you have the burnAmount and the fee you can subtract those two values from the actual amount being sent.

This can be written out as follows

     uint256 burnAmount = (_value * s_burnRate) / 1e18;
     uint256 fee = (_value * s_txFeeRate) / 1e18;

     uint256 amountToSend = _value - fee - burnAmount;

Once you have the amountToSend you can burn the burnAmount and update a new storage variable called s_rewardPool with the fee.

Worth noting, is you only want to deduct these fees when a token is being transferred from one address to another as opposed to when a new token is being minted. The way to do this is to check if the _from address is address(0). If it is, then return the function before calling the aforementioned functionality. The completed _update() function should be as follows.

    function _update(
        address _from,
        address _to,
        uint256 _value
    ) internal override(ERC20Upgradeable, ERC20PausableUpgradeable) whenNotPaused {
        if (_from == address(0)) {
            // Minting case, do not apply burn and fee
            ERC20Upgradeable._update(_from, _to, _value);
            return;
        }
        uint256 burnAmount = (_value * s_burnRate) / 1e18;
        uint256 fee = (_value * s_txFeeRate) / 1e18;

        uint256 amountToSend = _value - fee - burnAmount;

        if (burnAmount > 0) _burn(_from, burnAmount);

        if (amountToSend + burnAmount + fee != _value) revert InvalidSendAmount();
        s_rewardPool += fee;
        ERC20Upgradeable._update(_from, _to, amountToSend);
        emit RewardAdded(fee);
    }

Now that there is a reward pool accruing for token holders, you can include a simple claimRewards() function that allows token holders to claim a proportional reward for the number of tokens that they hold.

To calculate the reward you can check the balance of tokens that they have multiplied by the reward pool divided by the total supply of the token. Let's extract this into its own helper function called _calculateReward()


    function _calculateReward(address _account) internal view returns (uint256) {
        if (totalSupply() == 0) return 0;
        return (s_rewardPool * balanceOf(_account)) / totalSupply();
    }

Now the claimRewards() function can call this function and mint an appropriate reward based on the output of the calculateReward() function. The claimRewards() function can be written as follows

   function claimRewards() external whenNotPaused {
        uint256 reward = _calculateReward(msg.sender);
        s_rewardPool -= reward;
        _mint(msg.sender, reward);
        emit RewardClaimed(msg.sender, reward);
    }

The final bit of functionality this token needs is the ability to alter these rates going forward. Let's add those functions as follows

  function setBurnRate(uint256 newBurnRate) external whenNotPaused  {
        s_burnRate = newBurnRate;
  }

  function setTxFee(uint256 newRewardRate) external whenNotPaused  {
        s_txFeeRate = newRewardRate;
  }

Great! At this point, most of the token logic is now complete. The only issue you might want to restrict is who can call critical functions such as setBurnRate and setTxFee().

AccessControl

To address this you will add a new contract called AccessControl. The AccessControl will inherit from OpenZeppelin's AccessControlUpgradeable contract.

The AccessControl contract will allow for several roles including admin role, minter role, and blacklisted role. You will need a mapping to track the different addresses that have been assigned to specific roles. The completed AccessControl will look something like this.

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

import '@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol';

contract AccessControl is AccessControlUpgradeable {
    bytes32 public constant MINTER_ROLE = keccak256('MINTER_ROLE');
    bytes32 public constant PAUSER_ROLE = keccak256('PAUSER_ROLE');
    bytes32 public constant BLACKLIST_ADMIN_ROLE = keccak256('BLACKLIST_ADMIN_ROLE');

    // eligible minters
    mapping(address => bool) private _minterAddresses;

    // blacklisted (receiver) addresses
    mapping(address => bool) private _blacklistedAddresses;

    function initialize(address _defaultAdmin) public initializer {
        __AccessControl_init();
        _grantRole(DEFAULT_ADMIN_ROLE, _defaultAdmin);
        _grantRole(MINTER_ROLE, _defaultAdmin);
        _grantRole(BLACKLIST_ADMIN_ROLE, _defaultAdmin);
    }

    function addAdminRole(address _address) external onlyRole(DEFAULT_ADMIN_ROLE) {
        grantRole(DEFAULT_ADMIN_ROLE, _address);
    }

    function removeAdminRole(address _address) external onlyRole(DEFAULT_ADMIN_ROLE) {
        revokeRole(DEFAULT_ADMIN_ROLE, _address);
    }

    function addNewMinter(address _address) external onlyRole(DEFAULT_ADMIN_ROLE) {
        _minterAddresses[_address] = true;
    }

    function removeMinter(address _address) external onlyRole(DEFAULT_ADMIN_ROLE) {
        _minterAddresses[_address] = false;
    }

    function addToBlacklist(address _address) external onlyRole(DEFAULT_ADMIN_ROLE) {
        _blacklistedAddresses[_address] = true;
    }

    function removeFromBlacklist(address _address) external onlyRole(DEFAULT_ADMIN_ROLE) {
        _blacklistedAddresses[_address] = false;
    }

    function isAdmin(address _address) external view returns (bool) {
        return hasRole(DEFAULT_ADMIN_ROLE, _address);
    }

    function isWhitelistedMinter(address _address) external view returns (bool) {
        return _minterAddresses[_address];
    }

    function isBlacklistedReceiver(address _address) external view returns (bool) {
        return _blacklistedAddresses[_address];
    }
}

Note: For blacklisting, we included a simplified mapping to collect any potential blacklisted addresses from receiving your token. In production, it would be recommended to use a service such as ChainAnalysis, which has an easy-to-use Oracle that you can integrate with your contract that tracks sanctioned addresses that regulators would prohibit your token from being sent to.

Now with your AccessControl set you can return to your NativeToken contract to integrate with the AccessControl contract.

First, you need to import the AccessControl contract to your token.

import './AccessControl.sol';

Next, in the initialize function of your Token contract, make sure to pass in the address of your AccessControl contract and then store in a storage variable.

Your initialize function should now look like this.

  function initialize(
        AccessControl _accessControl,
        uint256 _burnRate,
        uint256 _txFeeRate
    ) public initializer {
        __ERC20_init('USD Token', 'USD');
        __ERC20Burnable_init();
        __ERC20Pausable_init();
        __ERC20Permit_init('USD Token');

        s_accessControl = _accessControl;

        s_burnRate = _burnRate;
        s_txFeeRate = _txFeeRate;
    }

Now you can add a modifier to use the AccessControl contract to restrict certain functionality to specific addresses granted a given role.

For example, isAdmin can restrict the msg.sender to be a specific whitelisted admin.

 modifier isAdmin() {
        if (s_accessControl.isAdmin(msg.sender)) revert OnlyAdmin();
        _;
    }

You can use the isAdmin modifier for your setBurnRate() and setTxFeeRate() modifiers.

Great! At this point, you should have all the functionality you need for your NativeToken! Now you need to deploy your token. For this, you can use a Factory contract to deploy the token on your home chain as well as other remote chains.

Token Factory

In this section, you will build the TokenFactory which will deploy the NativeToken you have just written on the home chain and also send a General Message Passing (GMP message) to a remote blockchain where you can deploy your token as well!

The factory will deploy a TransparentProxy for your token so that your token is upgradable. It will also use create3 so that your token is at the same address across all chains that you choose to deploy on.

Much like the NativeToken contract, this contract will also be upgradable. So let's start with the initialize function.

The initialize function will take in several params including the address of the InterchainTokenService, the addresses of Axelar's Gateway and GasService contracts (which you will need for sending GMP messages), and the address of your AccessControl contract.

You will also need the appropriate storage variables to store these parameters in. Your TokenFactory contract should look like this now.

import '@axelar-network/axelar-gmp-sdk-solidity/contracts/interfaces/IAxelarGasService.sol';
import '@axelar-network/axelar-gmp-sdk-solidity/contracts/interfaces/IAxelarGateway.sol';
import '@axelar-network/interchain-token-service/contracts/interfaces/IInterchainTokenService.sol';
import '@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol';  
contract TokenFactory {   
    IInterchainTokenService public s_its;
    AccessControl public s_accessControl;
    IAxelarGasService public s_gasService;
    IAxelarGateway public s_gateway;    

    /*************\
     INITIALIZATION
    /*************/
    /// @custom:oz-upgrades-unsafe-allow constructor
    constructor() {
        _disableInitializers();
    }

    function initialize(
        IInterchainTokenService _its,
        IAxelarGasService _gasService,
        IAxelarGateway _gateway,
        AccessControl _accessControl,
    ) external initializer {
        s_its = _its;
        s_gasService = _gasService;
        s_gateway = _gateway;
        s_accessControl = _accessControl;
    }
}

With your initializer being built you can now move on to actually deploying the token.

Let's write a specific function for deploying the token on your home chain called deployHomeNative()

Since your NativeToken requires a burnRate and txFeeRate you must make sure to pass that into your deployHomeNative() function to set those rates for the token. The function should look like this

function deployHomeNative(uint256 _burnRate, uint256 _txFeeRate) external onlyAdmin returns (address newTokenProxy) {}

Note the onlyAdmin modifier can be defined exactly as you did previously in the Token contract so that only your team can deploy the original token on the home chain.

Before writing up the rest of this function you will need to import the Create3.sol contract from the axelar-gmp-sdk dependency. This will give you access to the _create3() function that you will use to deploy the contract. Import the dependency as follows

import '@axelar-network/axelar-gmp-sdk-solidity/contracts/deploy/Create3.sol';

Now with the dependency imported, you can have your contract inherit from the Create3 contract.

When deploying with create3() you will need to pas in a bytes32 salt, which will be used to determine the address of your contract. For simplicity you can defined to salts in the beginning of the deployHomeNative() function. The first salt can be for the deployment of your proxy and the second for the deployment of your implementation.


bytes32 SALT_PROXY = 0x000000000000000000000000000000000000000000000000000000000000007B; //123
bytes32 SALT_IMPL = 0x00000000000000000000000000000000000000000000000000000000000004D2; //1234

These hexes correspond to the numbers 123 for the proxy and 1234 for the implementation.

Next, you can deploy the implementation of the NativeToken contract. (Recall since the contract is going to be an upgradable contract you need to deploy both a proxy and implementation).

To deploy the implementation simply call the _create3() internal function, which is defined in the Create3 contract that you inherited from before.

// Deploy implementation
address newTokenImpl = _create3(type(NativeToken).creationCode, SALT_IMPL);
if (newTokenImpl == address(0)) revert DeploymentFailed();

The _create3() takes the deployment bytecode of the NativeToken contract and the salt that you already defined. You can then run a sanity check to make sure that the deployment was in fact successful and did not return a 0 address.

Now with your implementation contract deployed, you can move on to the proxy. Deploying the proxy will be a bit different than deploying the implementation, as you will technically be deploying a TransparentUpgradeableProxy that is pointing to your implementation. To do this you can define a helper function called _getEncodedCreationCodeNative().

This function will take in your ProxyAdmin, your implementation address, your _burnRate, and your _txFeeRate.

Your function signature should look like this


    function _getEncodedCreationCodeNative(
        address _proxyAdmin,
        address _implAddr,
        uint256 _burnRate,
        uint256 _txFeeRate
    ) internal view returns (bytes memory proxyCreationCode) {}

Now in this function, you can set your initData for the Token itself. Recall these are the three params that you required in your initialize function for the NativeToken contract (accessControl, burnRate, and txFeeRate). You can encode these three params into a single bytes variable that will be used when initializing your proxy

bytes memory initData = abi.encodeWithSelector(NativeToken.initialize.selector, s_accessControl, s_its, _burnRate, _txFeeRate);

Now (still in your _getEncodedCreationCodeNative() function). You can encode the initData you just defined along with the _proxyAdmin and the _implAddr that the TransparentProxy will require when it's being deployed. You can then encode that with the creation code of the TransparentProxy itself. This all translates into the following line of code

proxyCreationCode = abi.encodePacked(type(TransparentUpgradeableProxy).creationCode, abi.encode(_implAddr, _proxyAdmin, initData));

So your completed _getEncodedCreationCodeNative() function now looks like this:

    function _getEncodedCreationCodeNative(
        address _proxyAdmin,
        address _implAddr,
        uint256 _burnRate,
        uint256 _txFeeRate
    ) internal view returns (bytes memory proxyCreationCode) {
        bytes memory initData = abi.encodeWithSelector(NativeToken.initialize.selector, s_accessControl, s_its, _burnRate, _txFeeRate);

        proxyCreationCode = abi.encodePacked(type(TransparentUpgradeableProxy).creationCode, abi.encode(_implAddr, _proxyAdmin, initData));
    }

Back to your deployHomeNative() function you can now trigger the _getEncodedCreationCodeNative() function underneath where your deployed the implementation contract. The output of the _getEncodedCreationCodeNative() function will be the creation code of your proxy contract.

bytes memory proxyCreationCode = _getEncodedCreationCodeNative(msg.sender, newTokenImpl, _burnRate, _txFeeRate);

With the proxyCreationCode now available, you can call _create3() once again (exactly as you did for the implementation contract) and pass in the proxyCreationCode as the first parameter and the SALT_PROXY as the salt value. Let's also store the address of the deployed proxy in a storage variable called s_nativeProxy.

// Deploy the proxy
newTokenProxy = _create3(proxyCreationCode, SALT_PROXY);
if (newTokenProxy == address(0)) revert DeploymentFailed();

s_nativeToken = newTokenProxy;

Now that you have stored the address of the proxy you can go back to the top of the deployHomeNative() function and add in a quick check to make sure that no native token has been deployed before. If one has been deployed then you can revert the transaction.

if (s_nativeToken != address(0)) revert TokenAlreadyDeployed();

Awesome! At this point once you call the deployHomeNativeFunction() you should now be able to deploy an upgradable version of the NativeToken stablecoin. But there is still more to do! Recall, that you want to be able to send cross-chain transactions with this token via ITS.

ITS Integration

To integrate your token with ITS you will need to deploy a token manager. A Token Manager is a separate contract that will help facilitate the integration of your token with ITS. Some of its responsibilities include setting flow limits (akin to rate limits) for your token, locking, burning, and minting tokens for cross-chain transactions. A token cannot send cross-chain transactions via ITS unless it has been successfully registered with its own unique token manager.

To deploy a token manager for your token you can simply call the function deployTokenManager() which is defined on the ITS contract that you passed into your initialize function.

The deployTokenManager() takes the following params. First, it needs a salt, this salt will be used to generate your tokens unique interchainTokenId. This token will be used to track your authentic token deployment across all chains that your token is wired up to do cross-chain transfers. Next, you need to pass in the destination chain in case you're doing a cross-chain deployment of the token manager. In this case, you're deploying the token manager for the chain you're already on since there is no destination chain needed you can simply pass in an empty string here. You then need to pass in the type of token manager you want. For this demo you can use the LOCK_UNLOCK token manager, this will lock your token on the home chain and unlock that token when it's bridged back from one of the remote chains. Next, you need to pass in the params. This is a bytes encoding of the address that will be the operator of the token and the address of the token itself. The operator is a role that allows a given address to adjust flow limits for the token. The fifth and final parameter is the gasValue this will be used to pay for the cost of a cross-chain token transfer, since you are doing a cross-chain deployment you can keep this as zero.

Still, in your deployHomeNative() function, you can call the deployTokenManager() function like this

s_its.deployTokenManager(
    S_SALT_ITS_TOKEN,
    '',
    ITokenManagerType.TokenManagerType.LOCK_UNLOCK,
    abi.encode(msg.sender.toBytes(), newTokenProxy),
    0
);

You can add a new event at the very bottom of the function to mark a successful token deployment and registration and that is it! The completed function should now look like this.

    function deployHomeNative(uint256 _burnRate, uint256 _txFeeRate) external onlyAdmin returns (address newTokenProxy) {
        if (s_nativeToken != address(0)) revert TokenAlreadyDeployed();

        bytes32 SALT_PROXY = 0x000000000000000000000000000000000000000000000000000000000000007B; //123
        bytes32 SALT_IMPL = 0x00000000000000000000000000000000000000000000000000000000000004D2; //1234

        // Deploy implementation
        address newTokenImpl = _create3(type(NativeToken).creationCode, SALT_IMPL);
        if (newTokenImpl == address(0)) revert DeploymentFailed();

        // Generate Proxy Creation Code
        bytes memory proxyCreationCode = _getEncodedCreationCodeNative(msg.sender, newTokenImpl, _burnRate, _txFeeRate);

        // Deploy proxy
        newTokenProxy = _create3(proxyCreationCode, SALT_PROXY);
        if (newTokenProxy == address(0)) revert DeploymentFailed();
        s_nativeToken = newTokenProxy;

        // Deploy ITS
        bytes32 tokenId = s_its.deployTokenManager(
            S_SALT_ITS_TOKEN,
            '',
            ITokenManagerType.TokenManagerType.LOCK_UNLOCK,
            abi.encode(msg.sender.toBytes(), newTokenProxy),
            0
        );

        emit NativeTokenDeployed(newTokenProxy, tokenId);
    }

Great! At this point when this function is called you will have successfully deployed an upgradeable NativeToken AND connected it to the Interchain Token Service!

Conclusion

In this section, you have built a custom ERC20 token that can burn tokens at a set burn rate for each token transfer and accrue proportional rewards for token holders. The token's roles are controlled by the Access Control contract. Finally, you wrote up a factory contract to deploy your custom token as an upgradable token using create3 to have a predictable contract address and integrated your newly deployed token with ITS.

In part two, you will continue to build out your Token Factory so that you can deploy your custom token on other blockchains.

The complete code for this section can be found here.