How to create an omni-chain token with LayerZero

Daemons.fi
7 min readJul 5, 2022

The article was inspired by this piece about creating a multichain NFT, which is based on this other article from the same author.

Daemons believes that the future is multichain, thus it needs a token that can be seamlessly transferred across chains and exist on multiple blockchains.

Holding on to this conviction, the research for the ultimate multichain solution for our platform’s token. After some digging, we discovered Stargate, a platform powered by LayerZero, that allows seamless transactions across multiple chains. Stargate gives Daemons the flexibility to be instantly deployed on other chains while sharing the platform’s token liquidity with minimal effort across all the supported blockchains.

The more the investigation went on, the more our hypothesis, that LayerZero might be a good solution, was reinforced. In order to validate our hypothesis, a proof-of-concept experimental setup has to be set up. This step was essential in finalizing the decision for our multi-chain implementation.

The rest of this article is documenting this proof-of-concept implementation and a step-by-step guide on how to create your own omni-chain token with LayerZero. The source code can be found here.

Modifying the token code

Transferring tokens on the same chain

ERC20 tokens are transferred using the transfer method. Whenever this function is invoked, the token contract subtracts amount from your wallet address and adds it to the address you are sending the tokens to. Easy to follow, very basic functionality.

Transferring tokens across chains

Trying to transfer tokens across chains, it gets a bit more complex. This is where LayerZero comes in to help with this.

LayerZero is a communication protocol, that allows contracts to send messages to contracts deployed on other chains. To include this feature in your code, you just need to add a couple of methods in your contracts: a method to send a message and a method to receive it. These methods are going to be referred to as the “send” and “receive” for now.

To allow a token to be transferred across chains, its contract will burn the token on the source chain (in the “send” method) and mint them on the destination chain (in the “receive” method). This procedure helps keep the token supply constant across chains.

Pre-requirements

This tutorial uses hardhat with TypeScript. The experimental environment setup will be briefly explained, however, it is advised that some experience with the framework should be acquired prior to using these kinds of tools.

LayerZero interfaces

First, we need to add to our project the LayerZero interfaces (shown below).

ILayerZeroEndpoint

// SPDX-License-Identifier: BUSL-1.1
pragma solidity ^0.8.4;
interface ILayerZeroEndpoint {
/** send a LayerZero message to the specified address at a LayerZero endpoint. */
function send(
uint16 _dstChainId,
bytes calldata _destination,
bytes calldata _payload,
address payable _refundAddress,
address _zroPaymentAddress,
bytes calldata _adapterParams
) external payable;
/** gets a quote in source native gas, for the amount that send() requires to pay for message delivery */
function estimateFees(
uint16 _dstChainId,
address _userApplication,
bytes calldata _payload,
bool _payInZRO,
bytes calldata _adapterParam
) external view returns (uint256 nativeFee, uint256 zroFee);
}

ILayerZeroReceiver

// SPDX-License-Identifier: BUSL-1.1
pragma solidity ^0.8.4;
interface ILayerZeroReceiver {
/** LayerZero endpoint will invoke this function to deliver the message on the destination */
function lzReceive(
uint16 _srcChainId,
bytes calldata _srcAddress,
uint64 _nonce,
bytes calldata _payload
) external;
}

Token contract

Now that the interfaces have been set, we can write the token contract.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;
import "./ILayerZeroEndpoint.sol";
import "./ILayerZeroReceiver.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract Omnitoken is ERC20, Ownable, ILayerZeroReceiver {
uint256 public constant MAX_SUPPLY = 100 * 1e18; // 100
// the LZ endpoint we will be sending messages to
address private lzEndpoint;
// a map containing the addresses of the OMNI token of various chains
mapping(uint16 => address) public omnitokenInOtherChains;
constructor(address _lzEndpoint) ERC20("Omnitoken", "OMNI") {
lzEndpoint = _lzEndpoint;
_mint(_msgSender(), MAX_SUPPLY);
}
/* ========== RESTRICTED FUNCTIONS ========== */ /** Sets the address of the OMNI token in a different chain */
function setOmnitokenAddressOnOtherChain(
uint16 _dstChainId,
address _address
) external onlyOwner {
omnitokenInOtherChains[_dstChainId] = _address;
}
/* ========== LAYER ZERO EXTERNAL FUNCTIONS ========== */ /** LayerZero endpoint will invoke this function to deliver the message on the destination */
function lzReceive(
uint16,
bytes memory,
uint64,
bytes memory _payload
) external override {
// let's make sure that only the LayerZero endpoint can call this method
require(msg.sender == lzEndpoint);
// decode destination address and amount, sent in the message payload
(address toAddress, uint256 amount) = abi.decode(
_payload,
(address, uint256)
);
// mint the amount of tokens at the destination address
_mint(toAddress, amount);
}
/** Sends the specified amount of tokens to an address on a different chain */
function crossChainTransfer(
address _to,
uint256 _amount,
uint16 _dstChainId
) external payable {
require(
balanceOf(_msgSender()) >= _amount,
"ERC20: amount exceeds balance"
);
// check if we have the address of the OMNI token for the specified chain
address omnitokenAddress = omnitokenInOtherChains[_dstChainId];
require(omnitokenAddress != address(0), "Chain not supported");
ILayerZeroEndpoint endpoint = ILayerZeroEndpoint(lzEndpoint); // encode payload
bytes memory payload = abi.encode(_to, _amount);
// estimate fees and check if user passed enough to complete the operation
(uint256 messageFee, ) = endpoint.estimateFees(
_dstChainId,
_to,
payload,
false,
bytes("")
);
require(msg.value >= messageFee, "Not enough to cover fee");
// send message to LayerZero relayer
endpoint.send{value: msg.value}(
_dstChainId, // the LZ id of the destination chain
abi.encodePacked(omnitokenAddress), // the OMNI token in the destination chain
payload, // the message payload
payable(_msgSender()), // where to send the excess fee
_msgSender(), // currently unused
bytes("") // currently unused
);
// burn the tokens on this chain
_burn(_msgSender(), _amount);
}
/* ========== LAYER ZERO VIEWS ========== */ /** Checks if the chain is supported by passing the LZ id of the destination chain */
function isChainSupported(uint16 _dstChainId) external view returns (bool) {
return omnitokenInOtherChains[_dstChainId] != address(0);
}
/** Estimates the fees for the message */
function estimateFees(
address _to,
uint256 _amount,
uint16 _dstChainId
) external view returns (uint256 nativeFee, uint256 zroFee) {
bytes memory payload = abi.encode(_to, _amount);
return
ILayerZeroEndpoint(lzEndpoint).estimateFees(
_dstChainId,
_to,
payload,
false,
bytes("")
);
}
}

The code is generously commented, so reading comprehension requirements are pretty low. You shouldn’t have much problem reading and understanding the code but you can also find a brief summary below:

  1. When the token is instantiated, a LayerZero endpoint address is passed. This will be the contract used to send and receive messages from.
  2. The address of the “OMNI” token is then added via setOmnitokenAddressOnOtherChain, so that the user can simply specify the transaction’s destination.
  3. When a cross-chain transfer is needed, simply call crossChainTransfer and specifying the destination address, the amount and the destination chain using the LayerZero chain IDs (NOTE: not the chainId. The IDs can be found in the LZ documentation).
  4. The receiver contract will extract the destination and the amount from the message and mint tokens there.

Deployment

In the this section, the deployment procedure is outlined in the following steps:

  1. Deploy on Arbitrum Testnet using this endpoint address (found here),
async function main() {
// deploy contract to ARBITRUM Testnet
const lzEndpoint = "0x4D747149A57923Beb89f22E6B7B97f7D8c087A00";
const Omnitoken = await ethers.getContractFactory("Omnitoken");
const omnitoken = await Omnitoken.deploy(lzEndpoint);
await omnitoken.deployed();
console.log("Omnitoken deployed to:", omnitoken.address);
}

and then execute the command with

npx hardhat run .\scripts\1_deploy_arbitrum.ts --network arb_rinkeby_testnet

2. Deploy to Fantom Testnet, using this endpoint (still found here),

async function main() {
// deploy contract to Fantom Testnet
const lzEndpoint = "0x7dcAD72640F835B0FA36EFD3D6d3ec902C7E5acf";
const Omnitoken = await ethers.getContractFactory("Omnitoken");
const omnitoken = await Omnitoken.deploy(lzEndpoint);
await omnitoken.deployed();
console.log("Omnitoken deployed to:", omnitoken.address);
}

then execute the command with

npx hardhat run .\scripts\2_deploy_fantom.ts --network ftm_testnet

These are the example addresses used in our example when deploying:

  • OMNI Arbitrum: 0x701301aE25c130144c975Ce84Dd0B6C363f9C44f
  • OMNI Fantom: 0xd3Cb8618C03269D5A01296D0f5A8003AF38B110B

3. Add the OMNI Arbitrum address to the Fantom token,

async function main() {
const OMNITOKEN_ARBITRUM_ADDRESS = "0x701301aE25c130144c975Ce84Dd0B6C363f9C44f";
const Omnitoken = await ethers.getContractFactory("Omnitoken");
const omnitoken = Omnitoken.attach("0xd3Cb8618C03269D5A01296D0f5A8003AF38B110B");
await omnitoken.setOmnitokenAddressOnOtherChain(10010, OMNITOKEN_ARBITRUM_ADDRESS);
console.log("SET");
}

and the other way around

async function main() {
const OMNITOKEN_FANTOM_ADDRESS = "0xd3Cb8618C03269D5A01296D0f5A8003AF38B110B";
const Omnitoken = await ethers.getContractFactory("Omnitoken");
const omnitoken = Omnitoken.attach("0x701301aE25c130144c975Ce84Dd0B6C363f9C44f");
await omnitoken.setOmnitokenAddressOnOtherChain(10012, OMNITOKEN_FANTOM_ADDRESS);
console.log("SET");
}

5. Test by sending tokens from Fantom To Arbitrum

async function main() {
const [owner] = await ethers.getSigners();
const amount = ethers.utils.parseEther("45");
const Omnitoken = await ethers.getContractFactory("Omnitoken");
const omnitoken = Omnitoken.attach("0xd3Cb8618C03269D5A01296D0f5A8003AF38B110B");
const expectedFeesRaw = (await omnitoken.estimateFees(owner.address, amount, 10010))[0];
const expectedFees = bnToNumber(expectedFeesRaw);
console.log("Expected fees:", expectedFees);
console.log("Sending")
await omnitoken.crossChainTransfer(
owner.address,
amount,
10010, // <-- Arbitrum Chain Id
{ value: ethers.utils.parseEther("1.25") }
);
console.log("Sent from FANTOM to ARBITRUM:");
}

by executing

npx hardhat run .\scripts\5_send_tokens_from_ftm_to_arbi.ts --network ftm_testnet

To verify that the operation was carried out successfully, the OMNI balance must decrease on Fantom and increase on Arbitrum. Accordingly, the token’s maximum supply will also change, guaranteeing a fixed token supply.

Closing Thoughts

This tutorial has demonstrated how easy is to code a cross-chain token utilizing the LayerZero infrastructure. However:

  1. The code has some limitations that should be addressed in order for the code to be production-ready. For example, it does not account for possible errors and does not retry to send messages when something goes wrong during message transfer.
  2. This is still the crypto wild west. LayerZero is still in the beta phase and it should be used with the utmost caution, especially for production-grade applications. Keep in mind that LayerZero provided interfaces might also change in terms of form, function or functionality in the near future, breaking compatibility for future releases.

Nonetheless, LayerZero seems to be a reliable infrastructure, and developing with it was extremely interesting. We, at Daemons are very excited to adopt this new cross-chain standard and see what the future holds for this very promising project.

Code here: https://github.com/daemons-fi/Omnichain-Token
LayerZero testnet addresses:
https://layerzero.gitbook.io/docs/technical-reference/testnet/testnet-addresses

--

--

Daemons.fi

Daemons.fi allows you to script operations on the blockchain and have them executed automatically once the conditions you established become true