Building Multichain Stablecoins: Part Two

Ben WeinbergBen Weinberg
14 min read

Objective

In section one, you built an upgradable custom ERC20 stablecoin, that accrues value for token holders and burns a percentage of each token transfer to address token inflation. You also began building a factory contract that integrated with Axelar's Interchain Token Service. The factory deployed your custom ERC20 as an upgradable token using create3. In this section, you will build out the functionality to deploy your token from your home chain onto a remote chain via your factory contract. You will also build out the functionality to upgrade your token to a simple V2 version. Finally, you will be able to test the token by actually sending a cross-chain transaction!

Deploy On Remote Chain

Let's build out a separate function called deployRemoteSemiNativeToken() to handle this deployment. This function will be used to deploy an instance of the SemiNativeToken discussed earlier. Recall, Semi Native tokens will not have the transaction fee redistribution and burning functionality that Native tokens have, these will be simpler tokens merely to provide some pseudo presence for your stablecoin on a chain you may not want to operate your official token on yet.

Since there is no burnRate and txFeeRate needed here, all you need to pass into this function is the name of the destination chain you want to deploy your token on.

function deployRemoteSemiNativeToken(string calldata _destChain) external payable {}

To start you will want to compute the interchainTokenId that ITS will use to register the token. To compute your token's interchainTokenId you need to hash the string interchain-token-id with the address that is deploying the token with a unique salt. This can be done as follows

bytes32 computedTokenId = keccak256(abi.encode(keccak256('its-interchain-token-id'), address(this), S_SALT_ITS_TOKEN));

Next, you will need to construct and send your GMP message to the destination chain.

Your GMP message needs to be of type bytes so let's encode the different components of your GMP message in a bytes type object. The three unique messages you will be encoding here a first your computedTokenId, second the Semi Native Token's creationCode, and last the Semi Native Token's selector. This can be done as follows

bytes memory gmpPayload = abi.encode(computedTokenId, type(SemiNativeToken).creationCode, SemiNativeToken.initialize.selector);

Now that you have your encoded message you can interact with the Axelar GasService and Gateway to send your GMP message. If this is new to you, feel free to watch this tutorial for the basics of sending a GMP message.

To pay for the transaction with the Axelar Gas Service you must call the payNativeGasForContractCall() function. This function requires the address making the payment, the destination chain, the destination address, the gmp message, and the refund address (in the event of an overly high gas payment) as its parameters.

This can be written out as follows:

   s_gasService.payNativeGasForContractCall{ value: msg.value }(
            address(this),
            _destChain,
            address(s_deployer).toString(),
            gmpPayload,
            msg.sender
        );

Note the s_deployer variable that is referenced as the destination address. This is pointing to a storage variable called s_deployer that has yet to be defined.

It can be defined up top with the rest of your storage variable and passed in as the fifth parameter to your initialize function so that your initialize function now looks like this.

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

        S_SALT_ITS_TOKEN = 0x0000000000000000000000000000000000000000000000000000000000003039; //12345
    }

Now that your s_deployer function is available you can send your GMP message to the destination chain you need to call the callContract() function on the Axelar Gateway contract. It requires similar parameters to the previous function, namely the destination chain, destination address, and GMP message.

s_gateway.callContract(_destChain, address(s_deployer).toString(), gmpPayload);

Your deployRemoteSemiNativeToken() should now be complete. Note the conditional that was added at the beginning of the function that references a new mapping you need to add in storage called s_semiNativeTokens, this will be where you register all your remote SemiNativeTokens that you want your factory contract to be aware of. In this if statement, you are making sure that the SemiNativeToken has not yet been deployed on the remote chain you're attempting to deploy to.

    function deployRemoteSemiNativeToken(string calldata _destChain) external payable {
        if (s_semiNativeTokens[_destChain] != address(0) && s_nativeToken != address(0)) revert TokenAlreadyDeployed();

        bytes32 computedTokenId = keccak256(abi.encode(keccak256('its-interchain-token-id'), address(this), S_SALT_ITS_TOKEN));

        bytes memory gmpPayload = abi.encode(computedTokenId, type(SemiNativeToken).creationCode, SemiNativeToken.initialize.selector);

        s_gasService.payNativeGasForContractCall{ value: msg.value }(
            address(this),
            _destChain,
            address(s_deployer).toString(),
            gmpPayload,
            msg.sender
        );

        s_gateway.callContract(_destChain, address(s_deployer).toString(), gmpPayload);
    }

Now that the transaction has been sent from the contract on the source chain. It needs to be received on the destination chain.

Deployer Contract

Your factory contract is now complete! However, you need to have a factory on all the destination chains that your factory will interact with. To make the factory more modular you can break up the following logic that still needs to be built out into a separate contract that you can call Deployer.

This will also be an upgradable contract with a similar initialize() function to the factory.

contract Deployer {
   IInterchainTokenService public s_its;
   AccessControl public s_accessControl;
   IAxelarGateway public s_gateway;

   bytes32 public S_SALT_ITS_TOKEN;

   /// @custom:oz-upgrades-unsafe-allow constructor
   constructor() {
       _disableInitializers();
   }
  function initialize(IInterchainTokenService _its, AccessControl _accessControl, IAxelarGateway _gateway) external initializer {
        s_its = _its;
        s_accessControl = _accessControl;
        s_gateway = _gateway;

        S_SALT_ITS_TOKEN = 0x0000000000000000000000000000000000000000000000000000000000003039; //12345
    }
}

Notice the S_SALT_ITS_TOKEN is the exact same value as the S_SALT_ITS_TOKEN in the in the TokenFactory contract. This is to ensure that the ITS token that will be wired up here on the destination chain will map to the same interchainTokenId.

Execute

Recall where you left off in the TokenFactory contract. You were able to send a cross-chain message from the source chain via the deployRemoteSemiNativeToken() function. Now, to handle that message on the destination chain you can use the execute() function.

This function takes several parameters including the _commandId, _sourceChain, _sourceAddress, and _payload. The payload is the GMP message you sent from the source chain

function execute(bytes32 _commandId, string calldata _sourceChain, string calldata _sourceAddress, bytes calldata _payload) external {}

The first line that must be written here is a call via the AxelarGateway to validateContractCall(). This function will ensure that the payload being passed in is a message that has been validated and confirmed by the Axelar validator set rather than an invalid message from a potentially malicious caller. This can be called as follows

if (!s_gateway.validateContractCall(_commandId, _sourceChain, _sourceAddress, keccak256(_payload))) revert NotApprovedByGateway();

Now that you know the payload is valid you can decode it to get the actual GMP message you passed in from the source chain. Recall, in the TokenFactory contract on the src chain encoded a computed tokenId value, the creation code of the SemiNativeToken contract, and the selector for the SemiNativeToken contract. These values can be decoded using the abi.decode() function

(bytes32 computedTokenId, bytes memory semiNativeTokenBytecode, bytes4 semiNativeSelector) = abi.decode(
    _payload,
    (bytes32, bytes, bytes4)
);

With your data now available on the destination chain you can deploy the SemiNativeToken just like you deployed the origin NativeToken on the source chain.

Since the SemiNativeToken will also be an upgradable token you must deploy the implementation first followed by the proxy that will point to the implementation. Note the salt value being passed into _create3() here is the same value in the deployHomeNative() function, meaning that the implementation address of these tokens will be the same address.

address newTokenImpl = _create3(semiNativeTokenBytecode, 0x00000000000000000000000000000000000000000000000000000000000004D2);
if (newTokenImpl == address(0)) revert DeploymentFailed();

Next, you can call the getEncodedCreationCodeSemiNative() code, this will be very similar to the getEncodedCreationCodeNative() you wrote in the TokenFactory contract.

    function _getEncodedCreationCodeSemiNative(
        address _proxyAdmin,
        address _implAddr,
        bytes32 _itsTokenId,
        bytes4 semiNativeSelector
    ) internal view returns (bytes memory proxyCreationCode) {
        //init func args
        bytes memory initData = abi.encodeWithSelector(semiNativeSelector, s_its, _itsTokenId);

        //concat bytecode + init func args
        proxyCreationCode = abi.encodePacked(type(TransparentUpgradeableProxy).creationCode, abi.encode(_implAddr, _proxyAdmin, initData));
    }

Now back in your execute() function you can call the _getEncodedCreationCodeSemiNative(). The output of this will be your proxy contract's creation code

   bytes memory creationCodeProxy = _getEncodedCreationCodeSemiNative(
            address(this),
            newTokenImpl,
            computedTokenId,
            semiNativeSelector
        );

You can now deploy your proxy contract and store its address.

address newTokenProxy = _create3(creationCodeProxy, 0x000000000000000000000000000000000000000000000000000000000000007B);
if (newTokenProxy == address(0)) revert DeploymentFailed();
s_tokenProxy = ITransparentUpgradeableProxy(newTokenProxy);

The final thing you need to do is connect your newly deployed token to ITS. You can do this exactly as you did before, by calling the deployTokenManager().

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

Note, that the parameters passed in are exactly the same as in the TokenFactory contract, the only difference is the TokenManagerType. Rather than passing in a lock/unlock token manager, you can pass a mint/burn, the reason being that this execution is taking place on a remote chain rather than the source chain where you want to lock up bridged tokens.

Your completed execute() function should be as follows


    function execute(bytes32 _commandId, string calldata _sourceChain, string calldata _sourceAddress, bytes calldata _payload) external {
        if (!s_gateway.validateContractCall(_commandId, _sourceChain, _sourceAddress, keccak256(_payload))) revert NotApprovedByGateway();

        (bytes32 computedTokenId, bytes memory semiNativeTokenBytecode, bytes4 semiNativeSelector) = abi.decode(
            _payload,
            (bytes32, bytes, bytes4)
        );

        address newTokenImpl = _create3(semiNativeTokenBytecode, 0x00000000000000000000000000000000000000000000000000000000000004D2);
        if (newTokenImpl == address(0)) revert DeploymentFailed();

        bytes memory creationCodeProxy = _getEncodedCreationCodeSemiNative(
            address(this),
            newTokenImpl,
            computedTokenId,
            semiNativeSelector
        );

        address newTokenProxy = _create3(creationCodeProxy, 0x000000000000000000000000000000000000000000000000000000000000007B);
        if (newTokenProxy == address(0)) revert DeploymentFailed();

        s_tokenProxy = ITransparentUpgradeableProxy(newTokenProxy);

        // Deploy ITS
        s_its.deployTokenManager(
            S_SALT_ITS_TOKEN,
            '',
            ITokenManagerType.TokenManagerType.MINT_BURN,
            abi.encode(msg.sender.toBytes(), newTokenProxy),
            0
        );
    }

Optional Callback

Axelar allows for two-way callback functions to have an immediate transaction back from the destination chain to the source chain. In this case you can send the address of your newly deployed SemiNativeToken proxy back to your source chain's TokenFactory contract.

This can be done very easily in your execute() function in the Deployer contract. Right underneath where you called deployTokenManager() you can call the callContract() function.

s_gateway.callContract(_sourceChain, _sourceAddress, abi.encode(newTokenProxy));

This call will make a cross-chain transaction back to the source chain to the source address that sends the original transaction. You can encode the address of the newTokenProxy as the GMP message to be sent back to the TokenFactory contract.

Now back on the TokenFactory contract you will need to implement and execute() method just like you did on the Deployer contract to handle the incoming GMP message.

In this execute function you will of course need to implement the validateContractCall() function again and then you can decode the GMP message and store in the TokenFactory contract.

  function execute(bytes32 _commandId, string calldata _sourceChain, string calldata _sourceAddress, bytes calldata _payload) external {
        if (!s_gateway.validateContractCall(_commandId, _sourceChain, _sourceAddress, keccak256(_payload))) revert NotApprovedByGateway();
        s_semiNativeTokens[_sourceChain] = abi.decode(_payload, (address));
    }

Upgrade SemiNativeToken

The final bit of functionality for this demo is the ability to upgrade your token directly from the Deployer contract. In the event that you choose to upgrade your SemiNativeToken.

To upgrade your token's proxy you will need to trigger the upgradeAndCall() function from your Proxy Admin contract. In your Deployer function create a new function called upgradeSemiNativeToken()

function upgradeSemiNativeToken(address _proxyAdmin) external onlyAdmin {}

First, you need to deploy a new implementation contract for your proxy to point to. For the purpose of this demo you can simply deploy a new SemiNativeTokenV2 contract with the exact same code and add a new function to it called isV2() that returns a bool .

   address newTokenImpl = _create3(
            type(SemiNativeTokenV2).creationCode,
            0x0000000000000000000000000000000000000000000000000000000000003439
        );
        if (newTokenImpl == address(0)) revert DeploymentFailed();

Now with your upgradeAndCall() function deployed, you can upgrade the token proxy to the newTokenImpl that you just deployed.

ProxyAdmin(_proxyAdmin).upgradeAndCall(s_tokenProxy, newTokenImpl, '');

At this point, your proxy should successfully be pointing to a new implementation address!

Deploy

It is critical for the _create3() addresses written out in the Factory and Deployer contracts to work correctly that these contracts are also the same address otherwise the contracts they deploy will be different from one another. To deploy these contracts you can use Axelar's create3Deployer script from Axelar's gmp-sdk package.

You can deploy the contract via Hardhat Tasks. Let's start with Moonbase.

Moonbase

Define your task like this

task('deployMoonbase', 'deploy deployer on remote chain (Moonbase for testing').setAction(async (taskArgs, hre) => {}

Now in the task you can deploy the contract for Moonbase.

First, you need to call the getWallet() function, which is written up in the utils folder. This will get your live wallet for the Moonbase chain. Make sure to include your private key in the .env file for the wallet to work correctly.

const wallet = getWallet(chains[1].rpc, hre);

Now you can deploy your upgradable AccessControl and Deployer contract on Moonbase. To do this simply call the create3DeployContract() from the Create3Deployer script.

    const implAccessControl = await create3DeployContract(create3DeployerAddress, wallet, AccessControl, 1720, []);
    const implDeployer = await create3DeployContract(create3DeployerAddress, wallet, Deployer, 1721, []);

    const proxyAccess = await create3DeployContract(create3DeployerAddress, wallet, Proxy, 1722, [
        implAccessControl.address,
        wallet.address,
        '0x',
    ]);
    const proxyDeployer = await create3DeployContract(create3DeployerAddress, wallet, Proxy, 1723, [
        implDeployer.address,
        wallet.address,
        '0x',
    ]);

    console.log(`proxyAccess ${proxyAccess.address}`);
    console.log(`proxyDeployer ${proxyDeployer.address}`);

Once deployed you can initialize your two newly deployed contracts.

const AccessControlFactory = await ethers.getContractFactory('AccessControl');
const DeployerFactory = await ethers.getContractFactory('Deployer');

const proxyAccessControlInstance = await AccessControlFactory.attach(proxyAccess.address);
const proxyDeployerInstance = await DeployerFactory.attach(proxyDeployer.address);

await proxyAccessControlInstance.initialize(wallet.address);
await proxyDeployerInstance.initialize(chains[1].its, proxyAccess.address, chains[1].gateway);

To trigger this hardhat script in your CLI type

hh deployMoonbase --network moonbase

Celo

The same task can be set for the Celo deployment. The one difference is that this time you will need to pass in the address of the deployer that you TokenFactory will be interacting with.

This can be done with the addParam call.

task('deployHomeCelo', 'deploy factory on home chain, (celo for testing)')
    .addParam('deployer', 'Deployer on dest chain')
    .setAction(async (taskArgs, hre) => {
        const wallet = getWallet(chains[0].rpc, hre);

        const implAccessControl = await create3DeployContract(create3DeployerAddress, wallet, AccessControl, 1720, []);
        const implFactory = await create3DeployContract(create3DeployerAddress, wallet, Factory, 1721, []);

        const proxyAccess = await create3DeployContract(create3DeployerAddress, wallet, Proxy, 1722, [
            implAccessControl.address,
            wallet.address,
            '0x',
        ]);

        const proxyFactory = await create3DeployContract(create3DeployerAddress, wallet, Proxy, 1723, [
            implFactory.address,
            wallet.address,
            '0x',
        ]);

        console.log(`celo contract address: ${proxyFactory.address}`);

        const AccessControlFactory = await ethers.getContractFactory('AccessControl');
        const TokenFactoryFactory = await ethers.getContractFactory('TokenFactory');

        const proxyAccessControlInstance = await AccessControlFactory.attach(proxyAccess.address);
        const proxyFactoryInstance = await TokenFactoryFactory.attach(proxyFactory.address);

        await proxyAccessControlInstance.initialize(wallet.address);

        await proxyFactoryInstance.initialize(
            chains[0].its,
            chains[0].gasService,
            chains[0].gateway,
            proxyAccess.address,
            taskArgs.deployer,
        );
    });

Great! At this point once you run hh deployHomeCelo --deployer "<YOUR_DEPLOYER_ADDRESS>" --network celo you should get your home chain's TokenFactory address in the cli.

Test

Let's now test this contract using the hardhat cli

To wire up your contract to your cli you type in

hh console --network <YOUR_HOME_CHAIN>

const Contract = await ethers.getContractFactory("TokenFactory")
const contract = await Contract.attach("<YOUR_CONTRACT_ADDRESS>")

Now with your contract connected to the cli, let's use your newly deployed TokenFactory on the home chain to deploy a NativeToken on your home chain. You can do this by calling the deployHomeNative() function.

await contract.deployHomeNative()

this should return a transaction receipt in your CLI and store the deployed instance of your contract in the s_tokenProxy storage variable. Query the variable to make sure you have the address stored correctly.

await contract.s_tokenProxy()
--> <"YOUR_TOKEN_ADDRESS"> //should be returned

Now with your token deployed on the home chain you can call the deployRemoteSemiNativeToken() on the remote chain.

await contract.deployRemoteSemiNativeToken("moonbeam", {value: "1000000000000000000"})

This should return a transaction hash that you can view on the Axelarscan Block Explorer. While this cross-chain transaction is executing you can interact with your token on the home chain to mint some new tokens to your contract.

You can wire it up similarly to how you wired up the TokenFactory contract.

const Contract = await ethers.getContractFactory("NativeToken")
const contract = await Contract.attach("<YOUR_TOKEN_ADDRESS")

Now with your token wired up you can mint tokens to your account that you will transfer to the destination chain.

await contract.mint("YOUR_RECEIVING_ADDRESS", 1000000000000000000)

You may need to specify a gasLimit when running the previous transaction.

At this point you should have a balance of your new token in your wallet equal to what you just minted. You can now transfer this token between accounts to make sure that the burn and txFee deductions are working correctly.

Cross Chain Transfer

With your token now deployed on multiple chains and a balance minted on the home chain you can now transfer your token from the home chain to the remote chain.

If you set a lock/unlock transaction token manager on the home chain then you will need to approve the Interchain Token Service (ITS) contract to handle the tokens on your behalf. This is because ITS will call the transferFrom() function when sending the cross chain transaction.

await contract.approve("0xB5FB4BE02232B1bBA4dC8f81dc24C26980dE9e3C", 1000000000000000000)

You can now interact with ITS programmatically to trigger the cross-chain transfer or you can go through the explorer.

To send the transfer from the home chain to the remote chain you will need to call the interchainTransfer() function on the ITS contract.

The following params are required

  1. payableAmount: The gas amount to be paid for the cross-chain tx

  2. tokenId: The interchain token id of your token. You can find your tokenId in the logs of NativeTokenDeployed() event

  3. destinationChain: The chain where your Deployer contract was deployed to

  4. destinationAddress: The address you want to receive the tokens on the destination chain

  5. amount: Amount of tokens to be sent

  6. metadata: Data to be sent with your token. You can leave this as 0x for this example

  7. gasValue: The gas amount to be paid for the cross-chain tx (in wei this time)

This should trigger another cross-chain transaction that you can track on Axelarscan.

Once this transaction has executed you should now see your balance increment on the destination chain!

Upgrade

To upgrade the token you can call the upgradeSemiNativeToken() function your deployer

await contract.upgradeSemiNativeToken("<YOUR_PROXY_ADMIN_ADDRESS>")

Once this is executed. You can query the isV2() token function at the same deployed token proxy address at the destination chain, which should now be returning true

const Contract = await ethers.getContractFactory("SemiNativeTokenV2")
const contract = await Contract.attach("<YOUR_PROXY_ADDRESS>")

await contract.isV2()
-> true

Conclusion

Congratulations if you made it until the end of this tutorial! You have now deployed an upgradable custom stablecoin with transaction fee redistribution to token holders. You have used create3 to deploy all your contracts at static addresses across different chains.

If you were unable to code along you take a look at the completed code here.

Interchain Token Service is a new initiative with the ability to provide simple cross-chain integration for your ERC20 token, we are excited to see what you will build with ITS.

If you have a new token you wish to deploy or an existing token you are looking to integrate with ITS you are more than welcome to reach out to Interop Labs team to see how we can help.

0
Subscribe to my newsletter

Read articles from Ben Weinberg directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Ben Weinberg
Ben Weinberg