Build
Tutorials
Messaging

This tutorial shows how to send cross-chain messages between contracts on two EVM chains using ZetaChain’s messaging infrastructure.

Unlike universal apps deployed directly on ZetaChain, this approach allows you to keep all contract logic on connected EVM chains. Messages are automatically routed between them through ZetaChain, without requiring any contracts to be deployed on ZetaChain itself.

Why use this pattern? Unlike universal apps deployed directly on ZetaChain, here all business logic stays on the connected EVM chains you already know. ZetaChain merely transports the payload, no contract code is deployed on ZetaChain itself.

By the end of this tutorial, you will:

  • Deploy a messaging contract to two EVM testnets (Base and Ethereum Sepolia)
  • Link them for cross-chain communication
  • Send a message and token value from one to the other
  • Track the cross-chain transaction from source to destination

Make sure you have the following installed:

Create a new project using the messaging template:

npx zetachain new --project messaging

Install TypeSCcipt and Foundry dependencies:

cd messaging
yarn
forge soldeer update

Compile contracts:

forge build

Save your private key in an environment variable so shell scripts can read it:

PRIVATE_KEY=...

To enable cross-chain messaging, your contract must inherit from ZetaChain’s Messaging base contract and implement a few required functions.

Import the Messaging.sol contract from the ZetaChain standard contracts package:

import "@zetachain/standard-contracts/contracts/messaging/contracts/Messaging.sol";

Inherit from Messaging in your contract:

contract Example is Messaging { ... }

Initialize the contract with the required parameters in the constructor:

constructor(
    address payable _gateway,
    address owner,
    address _router
) Messaging(_gateway, owner, _router) {}

The Messa`ging base contract provides built-in access to Gateway and Router, and ensures your contract is correctly wired into ZetaChain’s cross-chain messaging system.

You must implement three core internal functions for handling message delivery and fallback:

onMessageReceive

This is called automatically on the destination chain when a cross-chain message arrives successfully.

function onMessageReceive(
    bytes memory data,
    bytes memory sender,
    uint256 amount,
    bytes memory asset
) internal override {
    //...
}

Use this function to decode the message and execute logic like updating state, triggering downstream calls, or transferring the received token value.

onMessageRevert

This is triggered if the destination contract’s onMessageReceive fails (e.g., due to invalid calldata or logic errors).

function onMessageRevert(
    bytes memory data,
    bytes memory sender,
    uint256 amount,
    bytes memory asset
) internal override {
    //...
}

onRevert

This is called when a message fails during routing before reaching the destination chain. It executes on the source chain.

function onRevert(RevertContext calldata context)
    external
    payable
    override
    onlyGateway
{
    if (context.sender != router) revert Unauthorized();
    //...
}

You can use this to refund the user, trigger compensation logic, or emit a notification.

Sending a Message

To initiate a cross-chain message, your contract must call the depositAndCall function on the EVM Gateway. This function is what hands off your message and optional token value to ZetaChain’s messaging layer for routing.

Depending on whether you're sending native gas (like ETH) or ERC-20 tokens, you’ll use one of the following two forms of depositAndCall.

If you want to send a message with ETH as the value:

gateway.depositAndCall{value: msg.value}(
    router,
    message,
    revertOptions
);

If you're sending supported ERC-20 tokens:

gateway.depositAndCall(
    router,
    amount,
    asset,
    message,
    revertOptions
);

asset is the ERC-20 token address being sent (must be supported by ZetaChain).

What’s Inside the Message Payload?

The message argument is a single bytes field. It is ABI-encoded and must follow a structure that the Universal Router on ZetaChain understands.

abi.encode(
    receiver,       // bytes: destination contract address on the target chain
    targetToken,    // address: ZRC-20 address of the token to transfer to the destination contract
    data,           // bytes: message payload (e.g., ABI-encoded "hello")
    gasLimit,       // uint256: gas to forward for execution on the target chain
    revertOptions   // struct: defines what to do on failure
)

If you're sending the string "hello" to a contract on Ethereum Sepolia, you might encode:

bytes memory data = abi.encode("hello");
bytes memory message = abi.encode(
    abi.encodePacked(receiver),  // Destination contract address (as bytes)
    targetToken,                 // Token to transfer on destination chain
    data,                        // ABI-encoded message
    300_000,                     // Gas limit
    revertOptions                // Struct specifying fallback behavior
);

This message is then passed to depositAndCall() and routed through ZetaChain to the destination chain, where it is decoded and passed into the destination contract’s onMessageReceive().

What Is the Universal Router?

When you send a cross-chain message via gateway.depositAndCall(...), the actual logic that handles routing and execution on ZetaChain is implemented inside a contract called the Universal Router.

This contract runs on ZetaChain and acts as the entry point for all cross-chain messaging logic. It is responsible for:

  • Parsing the message payload sent from the source chain

  • Swapping tokens (if necessary) into:

    • The destination chain's gas token to cover execution fees
    • The destination contract's target token to be delivered
  • Forwarding the message and token to the destination contract

  • Handling fallback logic in case the destination call fails

All contracts that use the Messaging base contract share the same Universal Router. This shared router simplifies development, ensuring consistent behavior across all messaging-based apps.

The Universal Router ensures that your contract only needs to focus on sending an encoded payload, everything else, from gas handling to token transfer to delivery mechanics, is managed for you.

You don’t need to interact with the Universal Router directly. Just encode your message, call the Gateway, and ZetaChain will handle the rest.

🔧 Advanced: If you need more control, for example, to customize how tokens are swapped, route to different contracts, or handle messages differently, you can deploy your own instance of a router and point your contracts to it by passing its address to the Messaging constructor.

Deploy to Base Sepolia:

MESSAGING_BASE=$(./commands/index.ts deploy --rpc https://sepolia.base.org --private-key $PRIVATE_KEY | jq -r .contractAddress)

Deploy to Ethereum Sepolia:

MESSAGING_ETHEREUM=$(./commands/index.ts deploy --rpc https://sepolia.drpc.org --private-key $PRIVATE_KEY | jq -r .contractAddress)

Before two contracts can communicate across chains, they need to explicitly trust each other. This prevents malicious contracts from spoofing cross-chain messages. In this step, you’ll establish a bidirectional link between the two deployed messaging contracts.

Each contract needs to know:

  • The address of the counterpart contract on the remote chain
  • The chain ID of that remote chain

This is done using the setConnected() function in the messaging contract. ZetaChain’s cross-chain infrastructure will only deliver messages to a contract if it has registered the sender as trusted.

⚠️ If you skip this step or set the wrong address/chain ID, messages will be rejected on the destination chain.

./commands/index.ts connect \
  --contract $MESSAGING_BASE \
  --target-contract $MESSAGING_ETHEREUM \
  --rpc https://sepolia.base.org \
  --target-chain-id 11155111 \
  --private-key $PRIVATE_KEY
./commands/index.ts connect \
  --contract $MESSAGING_ETHEREUM \
  --target-contract $MESSAGING_BASE \
  --rpc https://sepolia.drpc.org \
  --target-chain-id 84532 \
  --private-key $PRIVATE_KEY

Once both directions are linked, the contracts can send and receive messages.

Now that your contracts are deployed and connected, you can send a message from one to the other.

This example sends the string "hello" from the contract on Base Sepolia to the contract on Ethereum Sepolia,

./commands/index.ts message \
  --rpc https://sepolia.base.org \
  --private-key $PRIVATE_KEY \
  --contract $MESSAGING_BASE \
  --target-contract $MESSAGING_ETHEREUM \
  --types string \
  --values hello \
  --target-token 0x05BA149A7bd6dC1F937fA9046A9e05C05f3b18b0 \
  --amount 0.005
FlagDescription
--rpc https://sepolia.base.orgThe RPC endpoint for the source chain (Base Sepolia). This is where the transaction will be sent.
--private-key $PRIVATE_KEYThe account that signs and funds the transaction on the source chain. It must hold the token being sent.
--contract $MESSAGING_BASEAddress of the messaging contract deployed on the source chain. This contract initiates the cross-chain call.
--target-contract $MESSAGING_ETHEREUMThe address of the contract on the destination chain. This is the final recipient of the message.
--types stringThe ABI type of the message you’re sending. This can be a single type (like string) or a tuple (e.g., string,uint256).
--values helloThe actual value to encode and send across chains, in this case, just the string "hello".
--target-token 0x...The ZRC20 token address on ZetaChain that represents the destination chain’s token. This tells ZetaChain what asset to transfer to the target contract.
--amount 0.005The total amount of tokens sent. A portion of this covers gas fees on the destination chain; the remainder is transferred to the destination contract.

How Amount is Handled

When you send a cross-chain message using --amount, you're not just transferring tokens, you’re also prepaying for gas on the destination chain. Here's what happens behind the scenes:

  1. You supply tokens on the source chain (e.g., Base ETH). This can be native gas (like ETH) or any supported ERC-20.

  2. You specify a target token via --target-token, which points to the ZRC-20 representing the token you want to deliver on the destination chain (e.g., Ethereum ETH).

  3. On ZetaChain:

    • A portion of the supplied amount is automatically swapped into the ZRC-20 version of the gas token for the destination chain (Ethereum ETH in this example). This is used to cover the execution cost of the message on the destination chain.
    • The remaining amount is swapped into the target token (which might be the same token) and forwarded to the destination contract.

This makes the system fully automated: you don’t need to hold or acquire the destination chain’s native token to interact with it.

{
  "contractAddress": "0xee2E8dfefd723e879CAa30A1DaD94046Fa3D24D4",
  "targetContract": "0x7c9BbA0630c9452F726bc15D0a73cdF769438efE",
  "targetToken": "0x05BA149A7bd6dC1F937fA9046A9e05C05f3b18b0",
  "message": "0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000568656c6c6f000000000000000000000000000000000000000000000000000000",
  "transactionHash": "0x939e230dd504efdf1fce31202a5980b4d0376430ddf535d080666256353c02c3",
  "amount": "0.005"
}

Use the transaction hash from the previous step to query its cross-chain status:

npx zetachain query cctx --hash 0x939e230dd504efdf1fce31202a5980b4d0376430ddf535d080666256353c02c3
84532 → 7001 ✅ OutboundMined
CCTX:     0xd88d92d0b9b0a2fde416bf6383e430b51de48114b0b03e7cc34e7f8d8df15cb7
Tx Hash:  0x939e230dd504efdf1fce31202a5980b4d0376430ddf535d080666256353c02c3 (on chain 84532)
Tx Hash:  0x8c368f6a3cfc55950b5d2b0d98c63d1904a79300490ec7d1c258505f372054e3 (on chain 7001)
Sender:   0xee2E8dfefd723e879CAa30A1DaD94046Fa3D24D4
Receiver: 0x5BD35697D4a62DE429247cbBDCc5c47F70477775
Message:  00000000000000000000000000000000000000000000000000000000000000a000000000000000000000000005ba149a7bd6dc1f937fa9046a9e05c05f3b18b000000000000000000000000000000000000000000000000000000000000000e000000000000000000000000000000000000000000000000000000000000f4240000000000000000000000000000000000000000000000000000000000000016000000000000000000000000000000000000000000000000000000000000000147c9bba0630c9452f726bc15d0a73cdf769438efe00000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000568656c6c6f000000000000000000000000000000000000000000000000000000000000000000000000000000ee2e8dfefd723e879caa30a1dad94046fa3d24d40000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000f42400000000000000000000000000000000000000000000000000000000000000000
Amount:   5000000000000000 Gas tokens

7001 → 11155111 ✅ OutboundMined
CCTX:     0x8952c9f95dfb5673a9fbfa2196842b750f5530f4931a55088b1276599328fd64
Tx Hash:  0xd88d92d0b9b0a2fde416bf6383e430b51de48114b0b03e7cc34e7f8d8df15cb7 (on chain 7001)
Tx Hash:  0xf30e4414087e8b5c81e257e8a97ac9105dde37cbbd6bb33a1691c4a30585507e (on chain 11155111)
Sender:   0x5BD35697D4a62DE429247cbBDCc5c47F70477775
Receiver: 0x7c9BbA0630c9452F726bc15D0a73cdF769438efE
Message:  00000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000001400000000000000000000000000000000000000000000000000009ad718280b1cf00000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000014a34000000000000000000000000000000000000000000000000000000000000018000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000568656c6c6f0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000014ee2e8dfefd723e879caa30a1dad94046fa3d24d40000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
Amount:   1074034700777807 Gas tokens

This confirms that the message has successfully moved from Base Sepolia through ZetaChain to Ethereum Sepolia.

You can verify the destination chain transaction on Etherscan:

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