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.
Prerequisites
Set Up Your Environment
Clone the example contracts repository and install the dependencies:
git clone https://github.com/zeta-chain/example-contracts
cd example-contracts/examples/hello
yarn
Universal App Contract
Let's review the contents of the Hello
contract:
// 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 typezContext
(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 appmessage
: the contents of thedata
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.
Understanding the Revert
Contract
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 {}
}
Start Localnet
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
Deploy the Contract
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
Interact with the Contract
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 theHello
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}
Simulating a Revert
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)
Handling the Revert
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 theRevert
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}
Conclusion
In this tutorial, you:
- Learned how to define a universal app contract that handles cross-chain messages.
- Deployed the
Hello
andRevert
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.
Source Code
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)