Build
Tutorials
Call & Deposit

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.

Before you begin, make sure you've completed the following tutorials:

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.

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 sender
  • zrc20 is the token address representing the source chain’s gas asset (or token sent)
  • amount is the token amount delivered
  • message 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.

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.

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
    })
);

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 a call with no token transfer, use zero address..
  • callOnRevert: whether to invoke onRevert if the call fails. The Gateway’s call does not support this, so it must be false.
  • 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 for onRevert. Since callOnRevert is false, set this to 0.

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.

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 in withdrawGasFeeWithGasLimit.
  • 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 support callOnRevert 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

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:

  1. Quote the destination gas fee in $ZRC20_ETHEREUM
  2. Approve the Universal App to spend that fee
  3. Send the cross-chain call through the Gateway

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.