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
Set Up Your Environment
Start by creating a new project and installing dependencies:
npx zetachain@latest new --project hello
cd hello
yarn
Universal Contract
A Universal App is a contract that implements the UniversalContract
interface.
// 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
: AMessageContext
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.
Option 1: Deploy on Localnet
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.
Option 2: Deploy on Testnet
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
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