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.