Connector: Send ZETA

In this tutorial you will learn how to create a contract capable of sending ZETA tokens between contracts on connected chains using cross-chain messaging.

  • This tutorial uses ZetaChain cross-chain messaging, but in this example we're only sending ZETA. You will learn how to send arbitrary data in a message in the Message tutorial.
  • Since this contract's purpose is to send ZETA, the contract in this example will accept ZETA as input. Example contracts in other tutorials will use native gas assets as input for convenience.
  • All the code will be generated for you by the template.
git clone
cd template

To create a new cross-chain messaging contract you will use the messaging Hardhat task available by default in the template.

npx hardhat messaging CrossChainZETA --fees zeta

The messaging task accepts one or more arguments: the name of the contract and a list of arguments (optionally with types). The arguments define the contents of the message that will be sent across chains. In this example the contract will only be sending ZETA, so the list of arguments is empty.

Use the --fee zeta to specify that this contract will be accepting ZETA tokens. You will learn more about this flag later in this tutorial.

The messaging task has created:

  • contracts/CrossChainMessage.sol: a Solidity cross-chain messaging contract
  • tasks/deploy.ts: a Hardhat task to deploy the contract on one or more chains
  • tasks/interact.ts: a Hardhat task to interact with the contract

It also modified hardhat.config.ts to import both deploy and interact tasks.

Let's review the contract:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.7;
import "@openzeppelin/contracts/interfaces/IERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@zetachain/protocol-contracts/contracts/evm/tools/ZetaInteractor.sol";
import "@zetachain/protocol-contracts/contracts/evm/interfaces/ZetaInterfaces.sol";
import "@zetachain/protocol-contracts/contracts/evm/Zeta.eth.sol";
contract CrossChainZeta is ZetaInteractor {
    error ErrorTransferringZeta();
    event CrossChainZetaEvent();
    event CrossChainZetaRevertedEvent();
    IERC20 internal immutable _zetaToken;
    constructor(address connectorAddress, address zetaTokenAddress) ZetaInteractor(connectorAddress) {
        _zetaToken = IERC20(zetaTokenAddress);
    function sendMessage(uint256 destinationChainId, uint256 zetaValueAndGas) external payable {
        if (!_isValidChainId(destinationChainId))
            revert InvalidDestinationChainId();
        bool success1 = _zetaToken.approve(address(connector), zetaValueAndGas);
        bool success2 = _zetaToken.transferFrom(msg.sender, address(this), zetaValueAndGas);
        if (!(success1 && success2)) revert ErrorTransferringZeta();
                destinationChainId: destinationChainId,
                destinationAddress: interactorsByChainId[destinationChainId],
                destinationGasLimit: 300000,
                message: abi.encode(),
                zetaValueAndGas: zetaValueAndGas,
                zetaParams: abi.encode("")

The contract:

State Variables:

  • _zetaToken: an internal immutable state variable that stores the address of the ZETA token contract.

The constructor passes connectorAddress to the ZetaInteractor constructor and initializes the _zetaToken state variables.

The sendMessage function is used to send ZETA tokens to a recipient contract on the destination chain. It first checks that the destination chain ID is valid. Then it approves the contract to spend an amount of ZETA

Cross-chain messaging fees are paid in ZETA tokens. This is convenient if the caller has ZETA tokens on the source chain. However, many users might only have native gas tokens. In this case it’s more convenient for a contract to accept native gas token, and swap it for ZETA.

To choose which token your contract accepts, use the --fees flag.

To make the contract accept ZETA, use --fees zeta. This value was used to create a contract above. Since the contract's only purpose is to send ZETA, it makes sense to also accept ZETA as a fee token.

To make the contract accept native gas tokens, use --fees native or skip this flag completely as this is the default option.

The sendMessage function uses connector.send to send a crosss-chain message with the following arguments wrapped in a struct:

  • destinationChainId: chain ID of the destination chain
  • destinationAddress: address of the contract receiving the message on the destination chain (expressed in bytes since it can be non-EVM)
  • destinationGasLimit: gas limit for the destination chain's transaction
  • message: arbitrary message to be parsed by the receiving contract on the destination chain. In this example it’s empty as we're only sending ZETA, not arbitrary data.
  • zetaValueAndGas: amount of ZETA tokens to be sent to the destination chain, ZetaChain gas fees, and destination chain gas fees (expressed in ZETA tokens)
  • zetaParams: optional ZetaChain parameters. Currently, not being used.

After handling the fees the contract calls connector.send to send zetaValueAndGas amount of ZETA to the destinationAddress contract on the destinationChainId blockchain. The message is empty as only ZETA tokens are being transferred.

The messaging task has created a Hardhat task to deploy the contract.

import { getAddress } from "@zetachain/protocol-contracts";
import { ethers } from "ethers";
import { task, types } from "hardhat/config";
import { HardhatRuntimeEnvironment } from "hardhat/types";
import type { ParamChainName } from "@zetachain/protocol-contracts";
const contractName = "CrossChainZeta";
const main = async (args: any, hre: HardhatRuntimeEnvironment) => {
  const networks = args.networks.split(",");
  const contracts: { [key: string]: string } = {};
  await Promise.all( (networkName: ParamChainName) => {
      contracts[networkName] = await deployContract(hre, networkName, args.json, args.gasLimit);
  for (const source in contracts) {
    await setInteractors(hre, source as ParamChainName, contracts, args.json, args.gasLimit);
  if (args.json) {
    console.log(JSON.stringify(contracts, null, 2));
const initWallet = (hre: HardhatRuntimeEnvironment, networkName: ParamChainName) => {
  const { url } = hre.config.networks[networkName] as any;
  const provider = new ethers.providers.JsonRpcProvider(url);
  const wallet = new ethers.Wallet(process.env.PRIVATE_KEY as string, provider);
  return wallet;
const deployContract = async (
  hre: HardhatRuntimeEnvironment,
  networkName: ParamChainName,
  json: boolean = false,
  gasLimit: number
) => {
  const wallet = initWallet(hre, networkName);
  const connector = getAddress("connector", networkName);
  const zetaToken = getAddress("zetaToken", networkName);
  const { abi, bytecode } = await hre.artifacts.readArtifact(contractName);
  const factory = new ethers.ContractFactory(abi, bytecode, wallet);
  const contract = await factory.deploy(connector, zetaToken, { gasLimit });
  await contract.deployed();
  if (!json) {
πŸš€ Successfully deployed contract on ${networkName}
πŸ“œ Contract address: ${contract.address}`);
  return contract.address;
const setInteractors = async (
  hre: HardhatRuntimeEnvironment,
  source: ParamChainName,
  contracts: { [key: string]: string },
  json: boolean = false,
  gasLimit: number
) => {
  if (!json) {
πŸ”— Setting interactors for a contract on ${source}`);
  const wallet = initWallet(hre, source);
  const { abi, bytecode } = await hre.artifacts.readArtifact(contractName);
  const factory = new ethers.ContractFactory(abi, bytecode, wallet);
  const contract = factory.attach(contracts[source]);
  for (const counterparty in contracts) {
    if (counterparty === source) continue;
    const counterpartyContract = ethers.utils.solidityPack(["address"], [contracts[counterparty]]);
    const chainId = hre.config.networks[counterparty].chainId;
    await (
      await contract.setInteractorByChainId(chainId, counterpartyContract, {
    if (!json) {
      console.log(`βœ… Interactor address for ${chainId} (${counterparty}) is set to ${counterpartyContract}`);
task("deploy", "Deploy the contract", main)
  .addParam("networks", "Comma separated list of networks to deploy to")
  .addOptionalParam("gasLimit", "Gas limit", 10000000,
  .addFlag("json", "Output JSON");

To establish cross-chain messaging between blockchains via ZetaChain, you need to deploy contracts capable of sending and receiving cross-chain messages to two or more blockchains connected to ZetaChain.

You can specify the desired chains by using a --networks parameter of the deploy task, which accepts a list of network names separated by commas. For instance, --networks sepolia_testnet,bsc_testnet.

The main function maintains a mapping of network names to their corresponding deployed contract addresses, iterating over the networks to deploy the contract on each one.

The contract's constructor requires three arguments: the connector contract's address, the ZETA token's address, and the ZETA token consumer contract's address. These addresses are obtained using ZetaChain's getAddress.

The main function subsequently sets interactors for each contract. An interactor is a mapping between a chain ID of the destination and the contract address on that chain.

When deploying to two chains (like Sepolia and BSC testnet), you will invoke setInteractorByChainId on a Sepolia contract and pass the BSC testnet chain ID (97) and the BSC testnet contract address. You then perform the same operation on a BSC testnet contract, passing the Sepolia chain ID (11155111) and the Sepolia contract address. If deploying to more than two chains, you must call setInteractorByChainId for each link between the chains.

The messaging task has also created a Hardhat task to interact with the contract:

import { task } from "hardhat/config";
import { HardhatRuntimeEnvironment } from "hardhat/types";
import { parseEther } from "@ethersproject/units";
import { getAddress } from "@zetachain/protocol-contracts";
const main = async (args: any, hre: HardhatRuntimeEnvironment) => {
  const [signer] = await hre.ethers.getSigners();
  const factory = await hre.ethers.getContractFactory("CrossChainZeta");
  const contract = factory.attach(args.contract);
  const destination = hre.config.networks[args.destination]?.chainId;
  if (destination === undefined) {
    throw new Error(`${args.destination} is not a valid destination chain`);
  const value = parseEther(args.amount);
  const zetaTokenAddress = getAddress("zetaToken", as any);
  const zetaFactory = await hre.ethers.getContractFactory("ZetaEth");
  const zetaToken = zetaFactory.attach(zetaTokenAddress);
  await (await zetaToken.approve(args.contract, value)).wait();
  const tx = await contract.connect(signer).sendMessage(destination, value);
  const receipt = await tx.wait();
  if (args.json) {
    console.log(JSON.stringify(tx, null, 2));
  } else {
    console.log(`πŸ”‘ Using account: ${signer.address}\n`);
    console.log(`βœ… The transaction has been broadcasted to ${}
πŸ“ Transaction hash: ${receipt.transactionHash}
task("interact", "Sends a message from one chain to another.", main)
  .addFlag("json", "Output JSON")
  .addParam("contract", "Contract address")
  .addParam("amount", "Token amount to send")
  .addParam("destination", "Destination chain");

The task accepts the following arguments:

  • contract: address of the contract on the source chain
  • amount: amount of native tokens to send with the transaction
  • destination: name of the destination chain

The main function uses the contract argument to attach to the contract on the source chain. It then uses the destination argument to obtain the destination chain's chain ID. The function subsequently calls the sendMessage contract method passing destination and value.

Clear the cache and artifacts, then compile the contract:

npx hardhat compile --force

Run the following command to deploy the contract to two networks:

npx hardhat deploy --networks sepolia_testnet,bsc_testnet
πŸš€ Successfully deployed contract on bsc_testnet
πŸ“œ Contract address: 0x65D661B68ff1466dedf80685450ac4c684b522BB

πŸš€ Successfully deployed contract on sepolia_testnet
πŸ“œ Contract address: 0xDEB42ce9d2F32caaA38Bf3107a054951E11575DF

πŸ”— Setting interactors for a contract on bsc_testnet
βœ… Interactor address for 11155111 (sepolia_testnet) is set to 0xdeb42ce9d2f32caaa38bf3107a054951e11575df

πŸ”— Setting interactors for a contract on sepolia_testnet
βœ… Interactor address for 97 (bsc_testnet) is set to 0x65d661b68ff1466dedf80685450ac4c684b522bb

Run the following command to send ZETA tokens from Sepolia to BSC testnet. Please, note that since the contract expect ZETA tokens as fees, the value of the --amount param is denominated in ZETA tokens. A fraction of the amount will be deducted as a cross-chain fee, the rest will be sent to the recipient on the destination chain.

npx hardhat interact --contract 0xDEB42ce9d2F32caaA38Bf3107a054951E11575DF --network sepolia_testnet --amount 3 --destination bsc_testnet
πŸ”‘ Using account: 0x4955a3F38ff86ae92A914445099caa8eA2B9bA32

βœ… The transaction has been broadcasted to sepolia_testnet
πŸ“ Transaction hash: 0x90db3fa9e6e169eadae84b2dbfac15c6829807029f7198f1fcf515e6fb4d04cb

You can check the broadcasted transaction on Sepolia's Etherscan: (opens in a new tab)

Next, you can track the progress of the cross-chain transaction:

npx hardhat cctx 0x90db3fa9e6e169eadae84b2dbfac15c6829807029f7198f1fcf515e6fb4d04cb
βœ“ CCTXs on ZetaChain found.

βœ“ 0xbf258794df4475fc79fec38f9afe3a92bee82477c3af1c9e61912e6b21408bb0: 11155111 β†’ 97: OutboundMined

You can find the source code for the example in this tutorial here: (opens in a new tab)