Build
Tutorials
First Universal App on Localnet

In this tutorial, you'll build a universal app contract that accepts messages from connected chains and emits corresponding events on ZetaChain. For example, a user on Ethereum can send the message "alice", and the universal contract on ZetaChain will emit an event with the string "Hello on ZetaChain, alice".

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

  • Define a universal app contract to handle messages from connected chains.
  • Deploy the contract to localnet.
  • Interact with the contract by sending a message from a connected EVM blockchain in localnet.
  • Gracefully handle reverts by implementing revert logic.

This tutorial relies on the gateway, which is currently available only on localnet. It will support testnet once the gateway is deployed there. Therefore, deploying this tutorial on testnet is not possible at this time.

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

Start by cloning the example contracts repository and installing the necessary dependencies:

git clone https://github.com/zeta-chain/example-contracts
cd example-contracts/examples/hello
yarn

The Hello contract is a simple universal app deployed on ZetaChain. It implements the UniversalContract interface, enabling it to handle cross-chain calls and token transfers from connected chains.

Here's the code for the Hello contract:

contracts/Hello.sol
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
 
import {RevertContext, RevertOptions} from "@zetachain/protocol-contracts/contracts/Revert.sol";
import "@zetachain/protocol-contracts/contracts/zevm/interfaces/UniversalContract.sol";
import "@zetachain/protocol-contracts/contracts/zevm/interfaces/IGatewayZEVM.sol";
import "@zetachain/protocol-contracts/contracts/zevm/GatewayZEVM.sol";
 
contract Hello is UniversalContract {
    GatewayZEVM public immutable gateway;
 
    event HelloEvent(string, string);
    event RevertEvent(string, RevertContext);
    error TransferFailed();
 
    constructor(address payable gatewayAddress) {
        gateway = GatewayZEVM(gatewayAddress);
    }
 
    function onCrossChainCall(
        zContext calldata context,
        address zrc20,
        uint256 amount,
        bytes calldata message
    ) external override {
        string memory name = abi.decode(message, (string));
        emit HelloEvent("Hello on ZetaChain", name);
    }
 
    function onRevert(RevertContext calldata revertContext) external override {
        emit RevertEvent("Revert on ZetaChain", revertContext);
    }
 
    function call(
        bytes memory receiver,
        address zrc20,
        bytes calldata message,
        uint256 gasLimit,
        RevertOptions memory revertOptions
    ) external {
        (, uint256 gasFee) = IZRC20(zrc20).withdrawGasFeeWithGasLimit(gasLimit);
        if (!IZRC20(zrc20).transferFrom(msg.sender, address(this), gasFee)) {
            revert TransferFailed();
        }
        IZRC20(zrc20).approve(address(gateway), gasFee);
        gateway.call(receiver, zrc20, message, gasLimit, revertOptions);
    }
 
    function withdrawAndCall(
        bytes memory receiver,
        uint256 amount,
        address zrc20,
        bytes calldata message,
        uint256 gasLimit,
        RevertOptions memory revertOptions
    ) external {
        (address gasZRC20, uint256 gasFee) = IZRC20(zrc20)
            .withdrawGasFeeWithGasLimit(gasLimit);
        uint256 target = zrc20 == gasZRC20 ? amount + gasFee : amount;
        if (!IZRC20(zrc20).transferFrom(msg.sender, address(this), target))
            revert TransferFailed();
        IZRC20(zrc20).approve(address(gateway), target);
        if (zrc20 != gasZRC20) {
            if (
                !IZRC20(gasZRC20).transferFrom(
                    msg.sender,
                    address(this),
                    gasFee
                )
            ) revert TransferFailed();
            IZRC20(gasZRC20).approve(address(gateway), gasFee);
        }
        gateway.withdrawAndCall(
            receiver,
            amount,
            zrc20,
            message,
            gasLimit,
            revertOptions
        );
    }
}

Let's break down what this contract does. The Hello contract inherits from the UniversalContract (opens in a new tab) interface, which requires the implementation of onCrossChainCall and onRevert functions for handling cross-chain interactions.

A state variable gateway of type GatewayZEVM holds the address of ZetaChain's gateway contract. This gateway facilitates communication between ZetaChain and connected chains.

In the constructor, we initialize the gateway state variable with the address of the ZetaChain gateway contract.

Handling Incoming Cross-Chain Calls

The onCrossChainCall function is a special handler that gets triggered when the contract receives a call from a connected chain through the gateway. This function processes the incoming data, which includes:

  • context: A zContext struct containing:
    • origin: The address (EOA or contract) that initiated the gateway call on the connected chain.
    • chainID: The integer ID of the connected chain from which the cross-chain call originated.
    • sender: Reserved for future use (currently empty).
  • zrc20: The address of the ZRC-20 token representing the asset from the source chain.
  • amount: The number of tokens transferred.
  • message: The encoded data payload.

Within onCrossChainCall, the contract decodes the name from the message and emits a HelloEvent to signal successful reception and processing of the message. It's important to note that onCrossChainCall can only be invoked by the ZetaChain protocol, ensuring the integrity of cross-chain interactions.

Making an Outgoing Contract Call

The call function demonstrates how a universal app can initiate a contract call to an arbitrary contract on a connected chain using the gateway. It operates as follows:

  1. Calculate Gas Fee: Determines the required gas fee based on the specified gasLimit. The gas limit represents the anticipated amount of gas needed to execute the contract on the destination chain.

  2. Transfer Gas Fee: Moves the calculated gas fee from the sender to the Hello contract. The user must grant the Hello contract permission to spend their gas fee tokens.

  3. Approve Gateway: Grants the gateway permission to spend the transferred gas fee.

  4. Execute Cross-Chain Call: Invokes gateway.call to initiate the contract call on the connected chain. The function selector and its arguments are encoded within the message. The gateway identifies the target chain based on the ZRC-20 token, as each chain's gas asset is associated with a specific ZRC-20 token.

Making a Withdrawal and Call

The withdrawAndCall function shows how a universal app can perform a token withdrawal with a call to an arbitrary contract on a connected chain using the gateway. The function executes the following steps:

  1. Calculate Gas Fee: Computes the necessary gas fee based on the provided gasLimit.

  2. Transfer Tokens: Moves the specified amount of tokens from the sender to the Hello contract. If the token being withdrawn is the gas token of the destination chain, the function transfers and approves both the gas fee and the withdrawal amount. If the target token is not the gas token, it transfers and approves the gas fee separately.

  3. Approve Gateway: Grants the gateway permission to spend the transferred tokens and gas fee.

  4. Execute Withdrawal and Call: Calls gateway.withdrawAndCall to withdraw the tokens and initiate the contract call on the connected chain.

The Echo contract is a simple contract deployed on an EVM-compatible chain. It can be invoked by the Hello contract on ZetaChain to demonstrate cross-chain communication.

contracts/Echo.sol
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
 
import {RevertContext} from "@zetachain/protocol-contracts/contracts/Revert.sol";
import "@zetachain/protocol-contracts/contracts/evm/GatewayEVM.sol";
 
contract Echo {
    GatewayEVM public immutable gateway;
 
    event RevertEvent(string, RevertContext);
    event HelloEvent(string, string);
 
    constructor(address payable gatewayAddress) {
        gateway = GatewayEVM(gatewayAddress);
    }
 
    function hello(string memory message) external payable {
        emit HelloEvent("Hello on EVM", message);
    }
 
    function onRevert(RevertContext calldata revertContext) external {
        emit RevertEvent("Revert on EVM", revertContext);
    }
 
    function call(
        address receiver,
        bytes calldata message,
        RevertOptions memory revertOptions
    ) external {
        gateway.call(receiver, message, revertOptions);
    }
 
    receive() external payable {}
 
    fallback() external payable {}
}

Localnet provides a simulated environment for developing and testing ZetaChain contracts locally.

To start localnet, open a terminal window and run:

npx hardhat localnet

This command initializes a local blockchain environment that simulates the behavior of ZetaChain protocol contracts.

With localnet running, open a new terminal window to compile and deploy the Hello and Echo contracts:

yarn deploy

You should see output indicating the successful deployment of the contracts:

🔑 Using account: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266

🚀 Successfully deployed "Hello" contract on localhost.
📜 Contract address: 0xE6E340D132b5f46d1e472DebcD681B2aBc16e57E

🔑 Using account: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266

🚀 Successfully deployed "Echo" contract on localhost.
📜 Contract address: 0xc3e53F4d16Ae77Db1c982e75a937B9f60FE63690

Note: The deployed contract addresses may differ in your environment.

In this example, you'll invoke the Echo contract on the connected EVM chain, which in turn calls the Hello contract on ZetaChain. Run the following command, replacing the contract addresses with those from your deployment:

npx hardhat echo-call \
  --contract 0xc3e53F4d16Ae77Db1c982e75a937B9f60FE63690 \
  --receiver 0xE6E340D132b5f46d1e472DebcD681B2aBc16e57E \
  --network localhost \
  --types '["string"]' alice

Parameters:

  • --contract: Address of the Echo contract on the connected EVM chain.
  • --receiver: Address of the Hello contract on ZetaChain.
  • --network: Network to interact with (localhost for localnet).
  • --types: ABI types of the message parameters (e.g., ["string"]).
  • alice: The message to send.

Overview:

  • EVM Chain: Executes the call function of the Echo contract.
  • EVM Chain: The call function invokes gateway.call, emitting a Called event.
  • ZetaChain: The protocol detects the event and triggers the Hello contract's onCrossChainCall.
  • ZetaChain: The Hello contract decodes the message and emits a HelloEvent.

To demonstrate how reverts are handled, let's intentionally send incorrect data. Instead of a string, we'll send a uint256:

npx hardhat echo-call \
  --contract 0xc3e53F4d16Ae77Db1c982e75a937B9f60FE63690 \
  --receiver 0xE6E340D132b5f46d1e472DebcD681B2aBc16e57E \
  --network localhost \
  --types '["uint256"]' 42

What Happens:

  • The Hello contract's onCrossChainCall attempts to decode a uint256 as a string, causing abi.decode to fail and revert the transaction.
  • The EVM chain detects the revert, and the transaction does not execute the intended logic.

To handle reverts gracefully, configure the gateway to call the Echo contract on the source chain upon a revert:

npx hardhat echo-call \
  --contract 0xc3e53F4d16Ae77Db1c982e75a937B9f60FE63690 \
  --receiver 0xE6E340D132b5f46d1e472DebcD681B2aBc16e57E \
  --network localhost \
  --revert-address 0xc3e53F4d16Ae77Db1c982e75a937B9f60FE63690 \
  --revert-message 0x \
  --call-on-revert \
  --types '["uint256"]' 42

Parameters:

  • --revert-address: Address of the Echo contract on the source EVM chain.
  • --revert-message: Data to pass to the Echo contract's onRevert function.
  • --call-on-revert: Flag indicating that the gateway should invoke a contract upon revert.

What Happens:

  • When the revert occurs, the gateway invokes the Echo contract's onRevert function on the EVM chain.
  • This allows you to handle the error gracefully within your application logic.

Now, let's perform the reverse operation: calling a contract on a connected EVM chain from the Hello universal app on ZetaChain. Execute the following command, replacing the contract addresses and ZRC-20 token address with those from your deployment:

npx hardhat hello-call \
  --contract 0xE6E340D132b5f46d1e472DebcD681B2aBc16e57E \
  --receiver 0xc3e53F4d16Ae77Db1c982e75a937B9f60FE63690 \
  --zrc20 0x2ca7d64A7EFE2D62A725E2B35Cf7230D6677FfEe \
  --function "hello(string)" \
  --network localhost \
  --types '["string"]' alice

Parameters:

  • --contract: Address of the Hello contract on ZetaChain.
  • --receiver: Address of the Echo contract on the connected EVM chain.
  • --zrc20: Address of the ZRC-20 token representing the gas token of the connected chain. This determines the destination chain.
  • --function: Function signature to invoke on the Echo contract (e.g., "hello(string)").
  • --network: Network to interact with (localhost for localnet).
  • --types: ABI types of the message parameters.
  • alice: The message to send.

To simulate a revert when calling an EVM contract from ZetaChain, send incorrect data types:

npx hardhat hello-call \
  --contract 0xE6E340D132b5f46d1e472DebcD681B2aBc16e57E \
  --receiver 0xc3e53F4d16Ae77Db1c982e75a937B9f60FE63690 \
  --zrc20 0x2ca7d64A7EFE2D62A725E2B35Cf7230D6677FfEe \
  --function "hello(string)" \
  --network localhost \
  --types '["uint256"]' 42

What Happens:

  • The Echo contract expects a string but receives a uint256, causing the function to fail and revert the transaction.

To handle reverts gracefully when calling an EVM contract from ZetaChain, include revert parameters:

npx hardhat hello-call \
  --contract 0xE6E340D132b5f46d1e472DebcD681B2aBc16e57E \
  --receiver 0xc3e53F4d16Ae77Db1c982e75a937B9f60FE63690 \
  --zrc20 0x2ca7d64A7EFE2D62A725E2B35Cf7230D6677FfEe \
  --function "hello(string)" \
  --network localhost \
  --revert-address 0xE6E340D132b5f46d1e472DebcD681B2aBc16e57E \
  --revert-message 0x \
  --call-on-revert \
  --types '["uint256"]' 42

Parameters:

  • --revert-address: Address of the Hello contract on ZetaChain.
  • --revert-message: Data to pass to the Hello contract's onRevert function.
  • --call-on-revert: Flag indicating that the gateway should invoke a contract upon revert.

What Happens:

  • Upon revert, the gateway calls the onRevert function of the Hello contract on ZetaChain.
  • This allows you to handle the error within your ZetaChain application.

To withdraw tokens and call a contract on a connected chain from a universal app, run the following command:

npx hardhat hello-withdraw-and-call \
  --contract 0xE6E340D132b5f46d1e472DebcD681B2aBc16e57E \
  --receiver 0xc3e53F4d16Ae77Db1c982e75a937B9f60FE63690 \
  --zrc20 0x9fd96203f7b22bCF72d9DCb40ff98302376cE09c \
  --function "hello(string)" \
  --amount 1 \
  --network localhost \
  --types '["string"]' hello

Parameters:

  • --contract: Address of the Hello contract on ZetaChain.
  • --receiver: Address of the Echo contract on the connected EVM chain.
  • --zrc20: Address of the ZRC-20 token representing the asset to withdraw.
  • --function: Function signature to invoke on the Echo contract.
  • --amount: Amount of tokens to withdraw.
  • --network: Network to interact with.
  • --types: ABI types of the message parameters.
  • hello: The message to send.

In this tutorial, you've:

  • Defined a universal app contract (Hello) to handle cross-chain messages.
  • Deployed both Hello and Echo contracts to a local development network.
  • Interacted with the Hello contract by sending messages from a connected EVM chain via the Echo contract.
  • Simulated revert scenarios and handled them gracefully using revert logic in both contracts.

By mastering cross-chain calls and revert handling, you're now prepared to build robust and resilient universal applications on ZetaChain.

You can find the complete source code for this tutorial in the example contracts repository (opens in a new tab).