In this tutorial, you’ll learn how to build a Universal App on ZetaChain that can:
- Handle incoming calls from connected EVM chains
- Make outgoing calls to a contract on a connected EVM chain
- Gracefully handle failures using revert handling
You’ll deploy two contracts:
- A Universal App on ZetaChain that processes cross-chain calls and can send calls back to a connected chain, optionally including token transfers.
- A Connected Contract on a connected EVM chain that can call into your Universal App and receive calls back from it.
This pattern demonstrates the core flows for two-way communication between ZetaChain and a connected chain:
- Incoming calls: connected chain → ZetaChain
- Outgoing calls: ZetaChain → connected chain
- Optional token transfers alongside calls in either direction
- Revert handling to recover gracefully from failed calls
By the end, you’ll have a minimal, working example of bi-directional contract calls with optional token movement and robust error handling.
Prerequisites
Before you begin, make sure you've completed the following tutorials:
Set Up Your Environment
Start by creating a new project from the call
template:
zetachain new --project call
cd call
Install dependencies:
yarn
Pull Solidity dependencies and compile the contracts:
forge soldeer update
forge build
Your workspace is now ready for adding the Universal App and Connected Contract logic. Next, we’ll walk through the key parts of the Universal App that handles incoming calls and makes outgoing calls to a connected chain.
Universal App
The Universal App runs on ZetaChain and implements the UniversalContract
interface. It receives calls from connected chains via the Gateway and can send
calls (with or without tokens) back to them.
Handling incoming calls
When a connected chain calls your Universal App, the Gateway invokes onCall
.
Here you decode the message and run your app’s logic:
function onCall(
MessageContext calldata context,
address zrc20,
uint256 amount,
bytes calldata message
) external override onlyGateway {
string memory name = abi.decode(message, (string));
emit HelloEvent("Hello on ZetaChain", name);
}
context
identifies the source chain and senderzrc20
is the token address representing the source chain’s gas asset (or token sent)amount
is the token amount deliveredmessage
is arbitrary calldata encoded on the source chain
Making outgoing calls
To call a contract on a connected chain from your Universal App, you must first approve the Gateway to spend the gas token:
IZRC20(zrc20).approve(address(gateway), gasFee);
Then use gateway.call
to send the cross-chain request:
gateway.call(
receiver, // bytes: address of the contract on the connected chain
zrc20, // ZRC-20 for the destination chain’s gas token
message, // calldata for the destination contract
callOptions, // gas limit, call type
revertOptions // revert handling
);
Withdrawing tokens and calling in one step
To send tokens and call a function on the connected chain in the same transaction, use:
gateway.withdrawAndCall(
receiver,
amount,
zrc20,
message,
callOptions,
revertOptions
);
This burns the ZRC-20 representation of the token on ZetaChain and releases the corresponding native asset or ERC-20 on the destination chain, while also executing the call.
Connected Contract
The Connected contract lives on a connected EVM chain and uses EVM Gateway to interact with your Universal App on ZetaChain.
A Connected contract is not required to make calls to a Universal App, you can call the Gateway directly from an EOA to trigger a cross-chain call.
In this tutorial, the Connected contract is simply an example showing how a contract on a connected chain can programmatically interact with a Universal App, making it easier to embed cross-chain calls into on-chain workflows.
Calling a Universal App (EVM → ZetaChain)
Send arbitrary calldata to a Universal App on ZetaChain:
gateway.call(
receiver, // address: Universal App on ZetaChain (EVM address)
message, // bytes: ABI-encoded payload for Universal onCall
revertOptions // revert behavior if delivery/execution fails
);
Once the cross-chain transaction is processed, the onCall
function of the
target Universal App on ZetaChain is executed with the provided calldata.
Deposit tokens
Deposit native gas (e.g., ETH) to an address/contract on ZetaChain:
gateway.deposit{value: msg.value}(receiver, revertOptions);
Deposit a supported ERC-20:
IERC20(asset).transferFrom(msg.sender, address(this), amount);
IERC20(asset).approve(address(gateway), amount);
gateway.deposit(receiver, amount, asset, revertOptions);
deposit
only transfers tokens to the receiver
on ZetaChain (EOA or contract)
and does not execute any code. The tokens arrive as ZRC-20.
Deposit and call
Send value and execute logic on ZetaChain in the same transaction.
Native gas:
gateway.depositAndCall{value: msg.value}(
receiver,
message,
revertOptions
);
ERC-20:
IERC20(asset).transferFrom(msg.sender, address(this), amount);
IERC20(asset).approve(address(gateway), amount);
gateway.depositAndCall(
receiver,
amount,
asset,
message,
revertOptions
);
After the cross-chain transaction is processed, the onCall
function of the
target Universal App on ZetaChain runs, receiving both the transferred tokens
and the provided calldata in the same execution.
Revert Handling
Cross-chain calls can fail for many reasons: insufficient gas on the destination
chain, a missing function in the target contract, or logic reverts in the called
function. To handle these cases gracefully, you can pass a RevertOptions
struct when making the call.
If the call fails, the Gateway invokes the onRevert
function of the
originating contract with a RevertContext
containing details about the
failure.
Example: Universal App onRevert
function onRevert(RevertContext calldata revertContext)
external
onlyGateway
{
emit RevertEvent("Revert on ZetaChain", revertContext);
}
You can use this hook to:
- Emit events for off-chain monitoring
- Refund tokens to the original sender
- Retry or take compensating actions
Passing RevertOptions
When calling or withdrawing with a call, provide RevertOptions
to define:
- The revert address (where to send refunds)
- Whether to call
onRevert
- A custom revert message
- Gas limits for the revert call
Example when making an outgoing call:
gateway.call(
receiver,
zrc20,
message,
callOptions,
RevertOptions({
revertAddress: msg.sender,
callOnRevert: true,
abortAddress: address(0),
revertMessage: abi.encode("refund"),
onRevertGasLimit: 500_000
})
);
Option 1: Deploy on Testnet
Before deploying, you need a private key funded on both ZetaChain testnet and your connected EVM testnet (for example, Base Sepolia) and the Gateway addresses for each chain.
GATEWAY_ZETACHAIN=0x6c533f7fe93fae114d0954697069df33c9b74fd7
GATEWAY_BASE=0x0c487a766110c85d301d96e33579c5b317fa4995
RPC_ZETACHAIN=https://zetachain-athens-evm.blockpi.network/v1/rpc/public
RPC_BASE=https://sepolia.base.org
Deploy Universal to ZetaChain testnet
UNIVERSAL=$(forge create Universal \
--rpc-url $RPC_ZETACHAIN \
--private-key $PRIVATE_KEY \
--evm-version paris \
--broadcast \
--json \
--constructor-args $GATEWAY_ZETACHAIN | jq -r .deployedTo) && echo $UNIVERSAL
Deploy Connected to Base Sepolia
CONNECTED=$(forge create Connected \
--rpc-url $RPC_BASE \
--private-key $PRIVATE_KEY \
--evm-version paris \
--broadcast \
--json \
--constructor-args $GATEWAY_BASE | jq -r .deployedTo) && echo $CONNECTED
Make a call to the Universal App
Call the Connected contract on Base Sepolia. It forwards through the Gateway to
your Universal App on ZetaChain. After the cross-chain transaction is processed,
the Universal App’s onCall
executes.
cast send $CONNECTED \
--rpc-url $RPC_BASE \
--private-key $PRIVATE_KEY \
--json \
"call(address,bytes,(address,bool,address,bytes,uint256))" \
$UNIVERSAL \
$(cast abi-encode "f(string)" "hello") \
"(0x0000000000000000000000000000000000000000,false,$UNIVERSAL,0x,0)" | jq -r '.transactionHash'
The third parameter to the call
function is the RevertOptions struct:
(revertAddress, callOnRevert, abortAddress, revertMessage, onRevertGasLimit)
revertAddress
: address to receive refunded tokens if the call fails. For acall
with no token transfer, use zero address..callOnRevert
: whether to invokeonRevert
if the call fails. The Gateway’scall
does not support this, so it must befalse
.abortAddress
: address where the message is considered aborted if delivery fails. Use the universal contract address, so that if the call fails,onAbort
is called on the universal contract.revertMessage
: arbitrary bytes returned in the revert context.onRevertGasLimit
: gas allocated foronRevert
. SincecallOnRevert
isfalse
, set this to0
.
You can also run the same call with a command:
npx tsx ./commands connected call \
--rpc $RPC_BASE \
--contract $CONNECTED \
--private-key $PRIVATE_KEY \
--receiver $UNIVERSAL \
--types string \
--values hello \
--name Connected
After you broadcast a transaction on testnet, you can track its progress end-to-end using the ZetaChain CLI:
zetachain query cctx --hash $HASH
This command shows the full cross-chain transaction (CCTX) lifecycle, including its current status, source and destination chain events, and any error or revert details if execution fails. It's the easiest way to confirm when your cross-chain call has been delivered and processed successfully.
Make a call from the Universal App
Your Universal App on ZetaChain can initiate an outgoing call to a contract on a connected EVM chain. The Universal App pulls the destination gas fee in the chain’s gas ZRC-20, then calls the Gateway.
First, quote the exact fee using the destination gas limit:
GAS_LIMIT=500000
ZRC20_BASE=0x236b0DE675cC8F46AE186897fCCeFe3370C9eDeD
GAS_FEE=$(cast call --json $ZRC20_BASE \
"withdrawGasFeeWithGasLimit(uint256)(address,uint256)" \
$GAS_LIMIT \
--rpc-url $RPC_ZETACHAIN | jq -r '.[1]') && echo $GAS_FEE
Approve the Universal App to spend the quoted fee:
cast send $ZRC20_BASE \
"approve(address,uint256)" \
$UNIVERSAL \
$GAS_FEE \
--rpc-url $RPC_ZETACHAIN \
--private-key $PRIVATE_KEY
Make the cross-chain call:
cast send --json \
--rpc-url $RPC_ZETACHAIN \
--private-key $PRIVATE_KEY \
$UNIVERSAL \
"call(bytes,address,bytes,(uint256,bool),(address,bool,address,bytes,uint256))" \
$(cast abi-encode "f(bytes)" $CONNECTED) \
$ZRC20_BASE \
$(cast abi-encode "f(string)" "hello") \
"($GAS_LIMIT,false)" \
"($UNIVERSAL,false,$UNIVERSAL,0x,0)" | jq -r '.transactionHash'
$GAS_LIMIT
is the amount of gas forwarded to the destination call. It must match the value used inwithdrawGasFeeWithGasLimit
.- isArbitraryCall controls call type. Use false for an authenticated message, use true for an arbitrary function call payload.
RevertOptions:
revertAddress
is the Universal contract address, so if the outgoing call fails, a revert cross-chain transaction will be sent back to the Universal contract.callOnRevert
is true. Outgoing calls from ZetaChain supportcallOnRevert
because transactions from connected chains to ZetaChain incur no gas fees.abortAddress
is also the Universal contract address, so if the outgoing call fails and cannot be reverted, the abort is handled by the Universal contract.revertMessage
is left empty in this example to keep the payload minimal.onRevertGasLimit
is set to 0, because revert calls from connected chains to ZetaChain do not incur gas fees.
You can also run the same call with a command:
npx tsx ./commands universal call \
--rpc $RPC_ZETACHAIN \
--contract $UNIVERSAL \
--private-key $PRIVATE_KEY \
--receiver $CONNECTED \
--types string \
--values hello \
--name Universal \
--zrc20 $ZRC20_BASE
Option 2: Deploy on Localnet
Localnet lets you deploy and test both contracts entirely on your machine. It runs a local ZetaChain instance alongside connected EVM chains, so you can iterate quickly without waiting for testnet confirmations or dealing with faucets.
npx zetachain localnet start
This command launches Anvil with pre-funded accounts and deploys the ZetaChain
core contracts locally. Deployment metadata is saved under
~/.zetachain/localnet/
.
Extract the RPC URL, a funded private key, and the relevant contract addresses from the localnet registry:
RPC=http://localhost:8545
ZRC20_ETHEREUM=$(jq -r '."11155112".chainInfo.gasZRC20' ~/.zetachain/localnet/registry.json) && echo $ZRC20_ETHEREUM
PRIVATE_KEY=$(jq -r '.private_keys[0]' ~/.zetachain/localnet/anvil.json) && echo $PRIVATE_KEY
GATEWAY_ETHEREUM=$(jq -r '.["11155112"].contracts[] | select(.contractType == "gateway") | .address' ~/.zetachain/localnet/registry.json) && echo $GATEWAY_ETHEREUM
GATEWAY_ZETACHAIN=$(jq -r '.["31337"].contracts[] | select(.contractType == "gateway") | .address' ~/.zetachain/localnet/registry.json) && echo $GATEWAY_ZETACHAIN
Deploy the Universal App:
UNIVERSAL=$(forge create Universal \
--rpc-url $RPC \
--private-key $PRIVATE_KEY \
--broadcast \
--json \
--constructor-args $GATEWAY_ZETACHAIN | jq -r .deployedTo) && echo $UNIVERSAL
Deploy the Connected contract:
CONNECTED=$(forge create Connected \
--rpc-url $RPC \
--private-key $PRIVATE_KEY \
--broadcast \
--json \
--constructor-args $GATEWAY_ETHEREUM | jq -r .deployedTo) && echo $CONNECTED
This simulates a connected chain sending a message to the Universal App on ZetaChain:
npx tsx ./commands connected call \
--rpc $RPC \
--contract $CONNECTED \
--private-key $PRIVATE_KEY \
--receiver $UNIVERSAL \
--types string \
--values hello \
--name Connected
Once the cross-chain transaction is processed locally, the Universal App’s
onCall
will execute.
This sends a message in the opposite direction, from ZetaChain to the connected chain:
npx tsx ./commands universal call \
--rpc $RPC \
--contract $UNIVERSAL \
--private-key $PRIVATE_KEY \
--receiver $CONNECTED \
--types string \
--values hello \
--name Universal \
--zrc20 $ZRC20_ETHEREUM
The command will:
- Quote the destination gas fee in $ZRC20_ETHEREUM
- Approve the Universal App to spend that fee
- Send the cross-chain call through the Gateway
Conclusion
You’ve now built and tested a Universal App that demonstrates the core mechanics of two-way contract communication on ZetaChain. By deploying both the Universal App (on ZetaChain) and the Connected Contract (on a connected EVM chain), you learned how to:
- Receive and process incoming calls from connected chains through the Gateway.
- Send calls back from ZetaChain to a connected chain.
- Handle failures gracefully using revert handling so your cross-chain logic remains robust.
- Run the exact same flows on testnet or entirely on localnet for faster iteration.
This pattern is the backbone of building Universal dApps applications that are not limited to a single chain, but instead can orchestrate logic, assets, and data across multiple environments from one place.
From here, you can:
- Add more connected chains to expand your app’s reach.
- Extend the contract logic to support complex workflows like swaps, staking, or NFT transfers.
- Integrate your Universal App into larger protocols to unify liquidity and user experience across ecosystems.
With ZetaChain, these patterns work the same regardless of which chains you connect, making your application future-proof and chain-agnostic from day one.
Your next step is to take this minimal example and turn it into a real cross-chain feature for your project without ever having to think in “single chain” terms again.