Building Multichain Stablecoins: Part Two
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
payableAmount
: The gas amount to be paid for the cross-chain txtokenId
: The interchain token id of your token. You can find yourtokenId
in the logs ofNativeTokenDeployed()
eventdestinationChain
: The chain where yourDeployer
contract was deployed todestinationAddress
: The address you want to receive the tokens on the destination chainamount
: Amount of tokens to be sentmetadata
: Data to be sent with your token. You can leave this as0x
for this examplegasValue
: The gas amount to be paid for the cross-chain tx (inwei
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.
Subscribe to my newsletter
Read articles from Ben Weinberg directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by