In this tutorial, you will build a universal app contract that accepts messages
from connected chains and emits corresponding events on ZetaChain. For instance,
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".
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 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.
Prerequisites
Before starting, ensure you have completed the following:
Set Up Your Environment
Begin 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
yarnUniversal App Contract: Hello
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.
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 immutable 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 ZetaChain", revertContext);
}
function call(
bytes memory receiver,
address zrc20,
bytes calldata message,
uint256 gasLimit,
RevertOptions memory revertOptions
) external {
(, uint256 gasFee) = IZRC20(zrc20).withdrawGasFeeWithGasLimit(gasLimit);
IZRC20(zrc20).transferFrom(msg.sender, address(this), gasFee);
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 targetAmount = zrc20 == gasZRC20 ? amount + gasFee : amount;
IZRC20(zrc20).transferFrom(msg.sender, address(this), targetAmount);
IZRC20(zrc20).approve(address(gateway), targetAmount);
if (zrc20 != gasZRC20) {
IZRC20(gasZRC20).transferFrom(msg.sender, address(this), gasFee);
IZRC20(gasZRC20).approve(address(gateway), gasFee);
}
gateway.withdrawAndCall(
receiver,
amount,
zrc20,
message,
gasLimit,
revertOptions
);
}
}The Hello contract inherits from the
UniversalContract (opens in a new tab)
interface, which mandates the implementation of onCrossChainCall and
onRevert functions for cross-chain interactions.
A state variable gateway of type GatewayZEVM holds the address of
ZetaChain's gateway contract, enabling communication between chains.
The constructor function accepts the address of the ZetaChain gateway contract
and initializes the gateway state variable.
Handling Incoming Cross-Chain Calls
The onCrossChainCall function is a special handler that is triggered when the
contract receives a call from a connected chain through the gateway. This
function processes the incoming data with the following parameters:
context: AzContextstruct containing:origin: The EOA or contract address 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.
The onCrossChainCall function is restricted to be invoked solely by the
ZetaChain protocol. This ensures that callers cannot supply arbitrary values in
the context, maintaining the integrity of cross-chain interactions.
Within onCrossChainCall, the contract decodes the name from the message
and emits a corresponding event to signal successful reception and processing of
the message.
Making an Outgoing Contract Call
The call function exemplifies how a universal app can initiate a contract call
to any arbitrary contract on a connected chain using the Gateway. The function
operates as follows:
- 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 (connected) chain. - Transfer Gas Fee: Moves the calculated gas fee from the sender to the
Hellocontract. Note: The user must grant theHellocontract permission to spend their gas fee tokens. - Approve Gateway: Grants the gateway permission to spend the transferred gas fee.
- Execute Cross-Chain Call: Invokes
gateway.callto initiate the contract call on the connected chain. The function selector and its arguments are encoded within themessage. 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 demonstrates 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:
- Calculate Gas Fee: Computes the necessary gas fee based on the provided
gasLimit. - Transfer Tokens: Moves the specified
amountof tokens from the sender to theHellocontract. 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. - Execute Withdrawal and Call: Calls
gateway.withdrawAndCallto withdraw the tokens and initiate the contract call on the connected chain.
EVM Echo Contract
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.
// 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 {}
}Start Localnet
Localnet provides a simulated environment for developing and testing ZetaChain contracts locally.
To start localnet, open a terminal window and run:
npx hardhat localnetThis command initializes a local blockchain environment that mimics the behavior of ZetaChain protocol contracts.
Deploying the Contracts
With localnet running, open a new terminal window to compile and deploy the
Hello and Echo contracts:
yarn deployExpected output:
🔑 Using account: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
🚀 Successfully deployed "Hello" contract on localhost.
📜 Contract address: 0xE6E340D132b5f46d1e472DebcD681B2aBc16e57E
🔑 Using account: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
🚀 Successfully deployed "Echo" contract on localhost.
📜 Contract address: 0xc3e53F4d16Ae77Db1c982e75a937B9f60FE63690Node: deployed contract may be different.
Calling the Echo Contract from Hello
In this example, you will invoke the Echo contract on EVM, which in turn calls
the Hello contract on ZetaChain.
npx hardhat echo-call --contract 0xc3e53F4d16Ae77Db1c982e75a937B9f60FE63690 --receiver 0xE6E340D132b5f46d1e472DebcD681B2aBc16e57E --network localhost --types '["string"]' aliceParameters:
--contract: Address of theEchocontract on the connected EVM chain.--receiver: Address of theHellocontract on ZetaChain.--network: Network to interact with (localhostfor localnet).--types: ABI types of the message parameters (e.g.,["string"]).alice: The message to send.
Overview:
- EVM: Executes the
Echocontract'scallfunction. - EVM: The
callfunction invokesgateway.call, emitting aCalledevent. - ZetaChain: The protocol detects the event and triggers the
Hellocontract'sonCrossChainCall. - ZetaChain: The
Hellocontract decodes the message and emits aHelloEvent.
Simulating a Revert
To demonstrate revert handling, intentionally send incorrect data. Instead of a
string, send a uint256:
npx hardhat echo-call --contract 0xc3e53F4d16Ae77Db1c982e75a937B9f60FE63690 --receiver 0xE6E340D132b5f46d1e472DebcD681B2aBc16e57E --network localhost --types '["uint256"]' 42Overview:
- The
Hellocontract'sonCrossChainCallattempts to decode auint256as astring, causingabi.decodeto fail and revert the transaction. - The EVM chain detects the revert, and the transaction does not execute the intended logic.
Handling a Revert
To handle reverts, 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"]' 42Parameters:
--revert-address: Address of theEchocontract on the source EVM chain.--revert-message: Data to pass to theEchocontract'sonRevertfunction.--call-on-revert: Flag indicating that the gateway should invoke a contract upon revert instead of merely returning tokens.
Overview:
- When the revert occurs, the gateway invokes the
Echocontract'sonRevertfunction, allowing you to handle the error gracefully within your application logic.
Calling an EVM Contract from a Universal App
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:
npx hardhat hello-call --contract 0xE6E340D132b5f46d1e472DebcD681B2aBc16e57E --receiver 0xc3e53F4d16Ae77Db1c982e75a937B9f60FE63690 --zrc20 0x2ca7d64A7EFE2D62A725E2B35Cf7230D6677FfEe --function "hello(string)" --network localhost --types '["string"]' aliceParameters:
--contract: Address of theHellocontract on ZetaChain.--receiver: Address of theEchocontract 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 theEchocontract (e.g.,"hello(string)").--network: Network to interact with (localhostfor localnet).--types: ABI types of the message parameters (e.g.,["string"]).alice: The message to send.
Simulating a Revert
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"]' 42Overview:
- The
Echocontract expects astring, but receives auint256, causing the function to fail and revert the transaction.
Handling a Revert
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"]' 42Parameters:
--revert-address: Address of theHellocontract on ZetaChain.--revert-message: Data to pass to theHellocontract'sonRevertfunction.--call-on-revert: Flag indicating that the gateway should invoke a contract upon revert.
Overview:
- Upon revert, the gateway calls the specified
revert-addresscontract, allowing you to handle the error within your ZetaChain application.
Conclusion
In this tutorial, you accomplished the following:
- Defined a universal app contract (
Hello) to handle cross-chain messages. - Deployed both
HelloandEchocontracts to a local development network. - Interacted with the
Hellocontract by sending messages from a connected EVM chain via theEchocontract. - Simulated revert scenarios and handled them gracefully using revert logic in both contracts.
By understanding how to manage cross-chain calls and handle reverts you are now equipped to build robust and resilient universal applications on ZetaChain.
Source Code
Access the complete source code for this tutorial in the example contracts repository (opens in a new tab).