Build
Tutorials
First Universal App on Localnet

In this tutorial, you will create a simple universal app contract that accepts a message with a string and emits an event with that string when called from a connected chain. For example, a user on Ethereum will be able to send a message "alice" and the universal contract on ZetaChain will emit an event with the string "Hello on ZetaChain, alice".

You will learn how to:

  • Define your 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.
  • Handle reverts gracefully by implementing revert logic.

This tutorial depends on the gateway, which is available on localnet but not yet deployed on testnet. It will be compatible with testnet after the gateway is deployed. In other words, you can't deploy this tutorial on testnet yet.

Clone the example contracts repository and install the dependencies:

git clone https://github.com/zeta-chain/example-contracts

cd example-contracts/examples/hello

yarn

Let's review the contents of 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 gateway;
 
    event HelloEvent(string, string);
    event RevertEvent(string, RevertContext);
 
    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 EVM", revertContext);
    }
}

Hello is a simple contract that inherits from the UniversalContract interface (opens in a new tab), which defines the required functions for cross-chain communication.

The contract declares a state variable of type GatewayZEVM that stores a reference to the ZetaChain's gateway contract.

The constructor function accepts the address of the ZetaChain gateway contract and initializes the gateway state variable.

onCrossChainCall is a function that is executed when the contract is called from a connected chain through a gateway. The function receives the following inputs:

  • context: is a struct of type zContext (opens in a new tab) that contains the following values:
    • origin: EOA or contract caller address that called the gateway on a connected chain.
    • chainID: integer ID of the connected chain from which the omnichain contract was triggered.
    • sender (reserved for future use, currently empty)
  • zrc20: the address of the ZRC-20 token contract that represents an asset from a connected chain on ZetaChain.
  • amount: the amount of tokens that were sent to the universal app
  • message: the contents of the data field of the token transfer transaction.

The onCrossChainCall function should only be called by the ZetaChain protocol to prevent a caller from supplying arbitrary values in context.

onCrossChainCall decodes the name from the message and emits an event.

The Revert contract is used to handle reverts that occur on ZetaChain and allows you to define custom logic for such cases.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
 
import {RevertContext} from "@zetachain/protocol-contracts/contracts/Revert.sol";
 
contract Revert {
    event RevertEvent(string, RevertContext);
    event HelloEvent(string, string);
 
    function hello(string memory message) external {
        emit HelloEvent("Hello on EVM", message);
    }
 
    function onRevert(RevertContext calldata revertContext) external {
        emit RevertEvent("Revert on EVM", revertContext);
    }
 
    receive() external payable {}
 
    fallback() external payable {}
}

Localnet is a development environment that simulates the behavior of ZetaChain protocol contracts on a single local blockchain.

Start localnet by running:

npx hardhat localnet

Compile the contracts and deploy them to localnet: s

npx run deploy

You should see output similar to:

🔑 Using account: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266

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

🔑 Using account: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266

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

Use the evm-call script to execute the gateway.call method on the connected EVM chain. This method sends a message to the Hello contract on ZetaChain.

npx hardhat evm-call --receiver 0x67d269191c92Caf3cD7723F116c85e6E9bf55933 --network localhost --types '["string"]' alice

Parameters:

  • --receiver: The address of the Hello contract on ZetaChain.
  • --types: The ABI types of the message parameters.
  • alice: The message to send.

The EVM gateway processes the call and emits a "Called" event.

[EVM]: Gateway: 'Called' event emitted

ZetaChain picks up the event and executes the onCrossChainCall function of the Hello contract.

[ZetaChain]: Universal contract 0x67d269191c92Caf3cD7723F116c85e6E9bf55933 executing onCrossChainCall (context: {"origin":"0xA51c1fc2f0D1a1b8494Ed1FE312d7C3a78Ed91C0","sender":"0x735b14BB79463307AAcBED86DAf3322B1e6226aB","chainID":1}), zrc20: 0x91d18e54DAf4F677cB28167158d6dd21F6aB3921, amount: 0, message: 0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000005616c696365000000000000000000000000000000000000000000000000000000)

The Hello contract decodes the message and emits a HelloEvent.

[ZetaChain]: Event from onCrossChainCall: {"_type":"log","address":"0x67d269191c92Caf3cD7723F116c85e6E9bf55933","blockHash":"0x978e67898c41511075417bcb219fe35f18d11ec992a2d7bac80ca0a28c72155f","blockNumber":41,"data":"0x00000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000001248656c6c6f206f6e205a657461436861696e00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000005616c696365000000000000000000000000000000000000000000000000000000","index":0,"removed":false,"topics":["0x39f8c79736fed93bca390bb3d6ff7da07482edb61cd7dafcfba496821d6ab7a3"],"transactionHash":"0x8941f1f6015a43ce55bc1a55858a2a783c94108667197837f984ca2b0c9ba4a5","transactionIndex":0}

To demonstrate how reverts are handled, we'll intentionally cause a revert by sending unexpected data. Instead of a string, we'll send a uint256.

npx hardhat evm-call --receiver 0x67d269191c92Caf3cD7723F116c85e6E9bf55933 --network localhost --types '["uint256"]' 42

This will cause the abi.decode function in the onCrossChainCall to fail, triggering a revert.

[EVM]: Gateway: 'Called' event emitted
[ZetaChain]: Universal contract 0x67d269191c92Caf3cD7723F116c85e6E9bf55933 executing onCrossChainCall (context: {"origin":"0xA51c1fc2f0D1a1b8494Ed1FE312d7C3a78Ed91C0","sender":"0x735b14BB79463307AAcBED86DAf3322B1e6226aB","chainID":1}), zrc20: 0x91d18e54DAf4F677cB28167158d6dd21F6aB3921, amount: 0, message: 0x000000000000000000000000000000000000000000000000000000000000002a)

You'll see output indicating that an error occurred:

[ZetaChain]: Error executing onCrossChainCall: Error: transaction execution reverted (action="sendTransaction", data=null, reason=null, invocation=null, revert=null, transaction={ "data": "", "from": "0x735b14BB79463307AAcBED86DAf3322B1e6226aB", "to": "0xA51c1fc2f0D1a1b8494Ed1FE312d7C3a78Ed91C0" }, receipt={ "_type": "TransactionReceipt", "blobGasPrice": "1", "blobGasUsed": null, "blockHash": "0x18c7286736278b0fbb987115176dfa42cd77cf9ec224914a37f43694fc506189", "blockNumber": 48, "contractAddress": null, "cumulativeGasUsed": "36569", "from": "0x735b14BB79463307AAcBED86DAf3322B1e6226aB", "gasPrice": "10000000000", "gasUsed": "36569", "hash": "0x7367af3912dc16d52cf29bd7e7c005fe3bec090e360108c69db7b57a8aec4262", "index": 0, "logs": [  ], "logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", "root": "0xbf1838bfa460082241895d67c8789e5f7ecc0729e88965abe1eaed1ed77ba66d", "status": 0, "to": "0xA51c1fc2f0D1a1b8494Ed1FE312d7C3a78Ed91C0" }, code=CALL_EXCEPTION, version=6.13.2)

Since we didn't specify a revert address, the gateway on the EVM chain cannot handle the revert properly:

[EVM]: Tx reverted without callOnRevert: Error: transaction execution reverted (action="sendTransaction", data=null, reason=null, invocation=null, revert=null, transaction={ "data": "", "from": "0x735b14BB79463307AAcBED86DAf3322B1e6226aB", "to": "0xA51c1fc2f0D1a1b8494Ed1FE312d7C3a78Ed91C0" }, receipt={ "_type": "TransactionReceipt", "blobGasPrice": "1", "blobGasUsed": null, "blockHash": "0x18c7286736278b0fbb987115176dfa42cd77cf9ec224914a37f43694fc506189", "blockNumber": 48, "contractAddress": null, "cumulativeGasUsed": "36569", "from": "0x735b14BB79463307AAcBED86DAf3322B1e6226aB", "gasPrice": "10000000000", "gasUsed": "36569", "hash": "0x7367af3912dc16d52cf29bd7e7c005fe3bec090e360108c69db7b57a8aec4262", "index": 0, "logs": [  ], "logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", "root": "0xbf1838bfa460082241895d67c8789e5f7ecc0729e88965abe1eaed1ed77ba66d", "status": 0, "to": "0xA51c1fc2f0D1a1b8494Ed1FE312d7C3a78Ed91C0" }, code=CALL_EXCEPTION, version=6.13.2)

To handle the revert gracefully, we'll provide additional parameters to specify that the gateway should call the Revert contract on the source chain in case of a revert.

npx hardhat evm-call --receiver 0x67d269191c92Caf3cD7723F116c85e6E9bf55933 --call-on-revert --revert-address 0xE6E340D132b5f46d1e472DebcD681B2aBc16e57E --network localhost --types '["uint256"]' 42

Parameters:

  • --call-on-revert: Informs the gateway to handle reverts.
  • --revert-address: The address of the Revert contract on the source chain.
[EVM]: Gateway: 'Called' event emitted
[ZetaChain]: Universal contract 0x67d269191c92Caf3cD7723F116c85e6E9bf55933 executing onCrossChainCall (context: {"origin":"0xA51c1fc2f0D1a1b8494Ed1FE312d7C3a78Ed91C0","sender":"0x735b14BB79463307AAcBED86DAf3322B1e6226aB","chainID":1}), zrc20: 0x91d18e54DAf4F677cB28167158d6dd21F6aB3921, amount: 0, message: 0x000000000000000000000000000000000000000000000000000000000000002a)
[ZetaChain]: Error executing onCrossChainCall: Error: transaction execution reverted (action="sendTransaction", data=null, reason=null, invocation=null, revert=null, transaction={ "data": "", "from": "0x735b14BB79463307AAcBED86DAf3322B1e6226aB", "to": "0xA51c1fc2f0D1a1b8494Ed1FE312d7C3a78Ed91C0" }, receipt={ "_type": "TransactionReceipt", "blobGasPrice": "1", "blobGasUsed": null, "blockHash": "0xaa203c2d40f8c35f098542958a7f8268c222fc6d204968fe14f65e3b60036d7e", "blockNumber": 41, "contractAddress": null, "cumulativeGasUsed": "36569", "from": "0x735b14BB79463307AAcBED86DAf3322B1e6226aB", "gasPrice": "10000000000", "gasUsed": "36569", "hash": "0x2c339d4414b3691a749be036a0be8ce692d8b2ac0997069fc73e07cdf628d7fc", "index": 0, "logs": [  ], "logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", "root": "0x8ae10541b4c9486d97e3e477295449ae80e3db3238ca0d19bf53483ca32119a6", "status": 0, "to": "0xA51c1fc2f0D1a1b8494Ed1FE312d7C3a78Ed91C0" }, code=CALL_EXCEPTION, version=6.13.2)

You'll now see that the Revert contract's onRevert function is called:

[EVM]: Contract 0xE6E340D132b5f46d1e472DebcD681B2aBc16e57E executing onRevert (context: {"asset":"0x0000000000000000000000000000000000000000","amount":0,"revertMessage":"0x3078"})
[EVM]: Gateway: successfully called onRevert

The Revert contract emits an event:

[EVM]: Event from onRevert: {"_type":"log","address":"0xE6E340D132b5f46d1e472DebcD681B2aBc16e57E","blockHash":"0xa3778291eb4a9c8b352b0c251e8fb379ba88c80c624fcb9384a0b20e661321cb","blockNumber":42,"data":"0x00000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000d526576657274206f6e2045564d0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000023078000000000000000000000000000000000000000000000000000000000000","index":0,"removed":false,"topics":["0xd0ec07494fc6e006dfb6c9d8649b3ad7404ac3bf1d4bcd7741923c3937d84ff2"],"transactionHash":"0x78a33b424d066f9cbdaed683fd4609d6beeb36b936cce0e3111a8462b2189d64","transactionIndex":0}

In this tutorial, you:

  • Learned how to define a universal app contract that handles cross-chain messages.
  • Deployed the Hello and Revert contracts to a local development network.
  • Interacted with the Hello contract by sending messages from a connected EVM chain.
  • Simulated a revert scenario and handled it gracefully using the Revert contract.

By understanding how to manage cross-chain calls and handle reverts, you're well on your way to building robust universal applications on ZetaChain.

You can find the source code for the tutorial in the example contracts repo:

https://github.com/zeta-chain/example-contracts/tree/main/examples/hello (opens in a new tab)