Build
Tutorials
Connector Messaging

Cross-Chain Message

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

Set up your environment

git clone https://github.com/zeta-chain/template
cd template
yarn

Create the Contract

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

npx hardhat messaging CrossChainMessage message:string

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 the example above the message will have only one field: message of type string. If the type is not specified it is assumed to be string.

The optional --fees flag is missing, which means this contract will accept native gas tokens as input and swap it for ZETA.

Cross-Chain Messaging Contract

Let's review the contract:

contracts/CrossChainMessage.sol
// 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";
 
contract CrossChainMessage is ZetaInteractor, ZetaReceiver {
 
    event CrossChainMessageEvent(string);
    event CrossChainMessageRevertedEvent(string);
 
    ZetaTokenConsumer private immutable _zetaConsumer;
    IERC20 internal immutable _zetaToken;
 
    constructor(address connectorAddress, address zetaTokenAddress, address zetaConsumerAddress) ZetaInteractor(connectorAddress) {
        _zetaToken = IERC20(zetaTokenAddress);
        _zetaConsumer = ZetaTokenConsumer(zetaConsumerAddress);
    }
 
    function sendMessage(uint256 destinationChainId, string memory message) external payable {
        if (!_isValidChainId(destinationChainId))
            revert InvalidDestinationChainId();
 
        uint256 crossChainGas = 2 * (10 ** 18);
        uint256 zetaValueAndGas = _zetaConsumer.getZetaFromEth{
            value: msg.value
        }(address(this), crossChainGas);
        _zetaToken.approve(address(connector), zetaValueAndGas);
 
        connector.send(
            ZetaInterfaces.SendInput({
                destinationChainId: destinationChainId,
                destinationAddress: interactorsByChainId[destinationChainId],
                destinationGasLimit: 300000,
                message: abi.encode(message),
                zetaValueAndGas: zetaValueAndGas,
                zetaParams: abi.encode("")
            })
        );
    }
 
 
    function onZetaMessage(
        ZetaInterfaces.ZetaMessage calldata zetaMessage
    ) external override isValidMessageCall(zetaMessage) {
        (string memory message ) = abi.decode(
            zetaMessage.message, (string)
        );
 
        emit CrossChainMessageEvent(message);
    }
 
    function onZetaRevert(
        ZetaInterfaces.ZetaRevert calldata zetaRevert
    ) external override isValidRevertCall(zetaRevert) {
        (string memory message) = abi.decode(
            zetaRevert.message,
            (string)
        );
 
        emit CrossChainMessageRevertedEvent(message);
    }
 
}

The contract:

State Variables:

  • _zetaConsumer: a private immutable state variable that stores the address of ZetaTokenConsumer, which is used amond other things for getting ZETA tokens from native tokens to pay for gas when sending a message.
  • _zetaToken: an internal immutable state variable that stores the address of the ZETA token contract.

The contract defines two events: CrossChainMessageEvent emitted when a message is processed on the destination chain and CrossChainMessageRevertedEvent emitted when a message is reverted on the destination chain.

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

The sendMessage function is used to send a message to a recipient contract on the destination chain. It first checks that the destination chain ID is valid. Then it uses ZETA consumer to get the needed amount of ZETA tokens from the provided msg.value (amount of native gas assets sent with the function call), and approves the ZetaConnector to spend the zetaValueAndGas amount of ZETA tokens.

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
  • 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.

The onZetaMessage function processes incoming cross-chain messages. The function uses abi.decode to decode the contents of the message. After decoding the functions emits CrossChainMessageEvent event with the message content.

The onZetaRevert function handles the reverts of cross-chain messages. This function is triggered on the source chain if the message passing failed.

Both onZetaMessage and onZetaRevert use the isValidRevertCall modifier to ensure that the revert message is genuine and originates from the trusted source.

Deploy the Contract

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: 0x4036009aa206a5c4d3bDABaC7242b18ACc5655D5

🚀 Successfully deployed contract on sepolia_testnet
📜 Contract address: 0x5d5c88B669337686af75f97C817365164786C88a

🔗 Setting interactors for a contract on bsc_testnet
✅ Interactor address for 11155111 (sepolia_testnet) is set to 0x5d5c88b669337686af75f97c817365164786c88a

🔗 Setting interactors for a contract on sepolia_testnet
✅ Interactor address for 97 (bsc_testnet) is set to 0x4036009aa206a5c4d3bdabac7242b18acc5655d5

Send a Message

Send a message from Sepolia to BSC testnet using the contract address (see the output of the deploy task). Make sure to submit enough native gas tokens with --amount to pay for the transaction fees.

npx hardhat interact --message hello --contract 0x5d5c88B669337686af75f97C817365164786C88a --network sepolia_testnet --amount 0.01 --destination bsc_testnet
🔑 Using account: 0x4955a3F38ff86ae92A914445099caa8eA2B9bA32

✅ The transaction has been broadcasted to sepolia_testnet
📝 Transaction hash: 0x798f15cc214e8d5d595ba1944099134330439a4a30b76cc791e1ca60cd85d696

You can check the broadcasted transaction on Sepolia's Etherscan:

https://sepolia.etherscan.io/tx/0x798f15cc214e8d5d595ba1944099134330439a4a30b76cc791e1ca60cd85d696 (opens in a new tab)

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

npx hardhat cctx 0x798f15cc214e8d5d595ba1944099134330439a4a30b76cc791e1ca60cd85d696
✓ CCTXs on ZetaChain found.

✓ 0x9f3dfff6b1373a6717ce6a0e20d3e6e1591cf13e75634f385550d3e1a226c604: 11155111 → 97: OutboundMined

Source Code

You can find the source code for the example in this tutorial here:

https://github.com/zeta-chain/example-contracts/tree/main/messaging/message (opens in a new tab)