Build
Tutorials
First Universal App

In this tutorial, you will create a simple Universal App on ZetaChain. This app will emit an event when it receives a cross-chain call from a connected chain.

By the end of this tutorial, you will understand how to:

  • Build a basic Universal App
  • Deploy it on ZetaChain Localnet
  • Use a Gateway on a connected chain to call your Universal App

Start by creating a new project and installing dependencies:

npx zetachain@latest new --project hello
cd hello
yarn

A Universal App is a contract that implements the UniversalContract interface.

contracts/Universal.sol
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
 
import "@zetachain/protocol-contracts/contracts/zevm/GatewayZEVM.sol";
 
contract Universal is UniversalContract {
    GatewayZEVM public immutable gateway;
 
    event HelloEvent(string, string);
    error Unauthorized();
 
    modifier onlyGateway() {
        if (msg.sender != address(gateway)) revert Unauthorized();
        _;
    }
 
    constructor(address payable gatewayAddress) {
        gateway = GatewayZEVM(gatewayAddress);
    }
 
    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: ", name);
    }
}

The constructor takes ZetaChain’s Gateway address and stores it in a state variable. The Gateway is used for making outbound contract calls and token withdrawals.

A universal contract must implement the onCall function. This function is triggered when the contract receives a call from a connected chain via the Gateway. The function processes incoming data, which includes:

  • context: A MessageContext struct containing:
    • chainID: The chain ID of the connected chain that initiated the cross-chain call.
    • sender: The address (EOA or contract) that called the Gateway on the connected chain.
    • origin: Deprecated.
  • zrc20: The address of the ZRC-20 token representing assets from the source chain.
  • amount: The amount of tokens transferred.
  • message: The encoded payload data.

In this example, onCall decodes the message into a string and emits an event.

onCall should only be called by the Gateway to ensure that it is only called as a response to a call on a connected chain and that you can trust the values of the function parameters.

Localnet is a local development environment that simulates multiple connected EVM chains with Gateways.

Start Localnet:

npx zetachain localnet start

Compile your contracts:

npx hardhat compile --force

To deploy a universal contract, you need the Gateway address from Localnet. You can either copy it from the output or fetch it programmatically:

GATEWAY_ZETACHAIN=$(jq -r '.["31337"].contracts[] | select(.contractType == "gateway") | .address' ~/.zetachain/localnet/registry.json) && echo $GATEWAY_ZETACHAIN

Deploy the universal contract and provide the Gateway address in the constructor:

UNIVERSAL=$(npx hardhat deploy --network localhost --gateway "$GATEWAY_ZETACHAIN" --json | jq -r .contractAddress) && echo $UNIVERSAL

Make a Call to the Universal App

To call the universal app deployed on ZetaChain from a connected chain, send a transaction to the Gateway contract on the connected EVM chain.

Fetch the Gateway address for the connected chain:

GATEWAY_EVM=$(jq -r '.["11155112"].contracts[] | select(.contractType == "gateway") | .address' ~/.zetachain/localnet/registry.json)

Alternatively, you can copy the Gateway address directly from the table printed in the Localnet terminal output when Localnet starts.

Fetch a private key with pre-funded tokens on the connected chain:

PRIVATE_KEY=$(jq -r '.private_keys[0]' ~/.zetachain/localnet/anvil.json)

Execute the call method on the connected chain’s Gateway to send a message to the universal contract deployed on ZetaChain.

npx zetachain evm call \
  --rpc http://localhost:8545 \
  --gateway $GATEWAY_EVM \
  --receiver $UNIVERSAL \
  --private-key $PRIVATE_KEY \
  --types string \
  --values hello

Once the transaction is processed, you’ll see an [ZetaChain]: Event from onCall log in the Localnet terminal.

Set your private key in the .env file:

PRIVATE_KEY=...

Deploy the contract to ZetaChain’s testnet using the Gateway address from the Contract Addresses page:

UNIVERSAL=$(npx hardhat deploy --network zeta_testnet --gateway 0x6c533f7fe93fae114d0954697069df33c9b74fd7 --json | jq -r .contractAddress) && echo $UNIVERSAL

Make a transaction to the Gateway on Base Sepolia (or another connected testnet) to call the universal app on ZetaChain.

npx zetachain evm call \
  --chain-id 84532 \
  --receiver $UNIVERSAL \
  --private-key $PRIVATE_KEY \
  --types string \
  --values hello
Transaction hash: 0x89308870b0863c5ae48dc783059277cbcf4296b1b343413ac543418262a4ccbc

https://sepolia.basescan.org/tx/0x89308870b0863c5ae48dc783059277cbcf4296b1b343413ac543418262a4ccbc (opens in a new tab)

Track the status of the cross-chain transaction:

npx zetachain query cctx --hash 0x89308870b0863c5ae48dc783059277cbcf4296b1b343413ac543418262a4ccbc
84532 → 7001 ✅ OutboundMined
CCTX:     0x56f9bc09dc646b13aa713b56348e8a53ea39759146afad61e66973791b752e3b
Tx Hash:  0x89308870b0863c5ae48dc783059277cbcf4296b1b343413ac543418262a4ccbc (on chain 84532)
Tx Hash:  0x34edd96c8a7b2bd9d530de0e49bb5e8625204a77b77cc79133814e1814f79ebc (on chain 7001)
Sender:   0x4955a3F38ff86ae92A914445099caa8eA2B9bA32
Receiver: 0xFeb4F33d424D6685104624d985095dacab567151
Message:  0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000568656c6c6f000000000000000000000000000000000000000000000000000000