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
Prerequisites
Make sure you have the following installed:
- Node.js (opens in a new tab) v18+
- Yarn (opens in a new tab)
- Foundry (opens in a new tab)
- jq (opens in a new tab) for parsing JSON in shell scripts
- A funded private key for both Base Sepolia (84532) and Ethereum Sepolia (11155111)
Create a Project
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=...
Messaging Contract
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 Messaging Contracts
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)
Connect Two Contracts
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.
Send a Cross-Chain Message
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
Flag | Description |
---|---|
--rpc https://sepolia.base.org | The RPC endpoint for the source chain (Base Sepolia). This is where the transaction will be sent. |
--private-key $PRIVATE_KEY | The account that signs and funds the transaction on the source chain. It must hold the token being sent. |
--contract $MESSAGING_BASE | Address of the messaging contract deployed on the source chain. This contract initiates the cross-chain call. |
--target-contract $MESSAGING_ETHEREUM | The address of the contract on the destination chain. This is the final recipient of the message. |
--types string | The ABI type of the message you’re sending. This can be a single type (like string ) or a tuple (e.g., string,uint256 ). |
--values hello | The 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.005 | The 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:
-
You supply tokens on the source chain (e.g., Base ETH). This can be native gas (like ETH) or any supported ERC-20.
-
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). -
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"
}
Track the Cross-Chain Transaction
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: