Developers
Tutorials
First Universal App

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 a "Greet" event with a value "alice".

You will learn how to:

  • Use the Hardhat template to create a new universal app using a single command
  • Define your universal app contract to handle messages from connected chains
  • Deploy the contract to ZetaChain
  • Interact with the contract by sending a message from Ethereum testnet
  • Track an incoming cross-chain transaction

Prerequisites

Set Up Your Environment

Clone the Hardhat contract template:

git clone https://github.com/zeta-chain/template

cd template/contracts

yarn

Make sure that you've followed the Getting Started tutorial to set up your development environment, create an account and request testnet tokens.

Create the Contract

To create a new universal app contract, use the omnichain Hardhat task:

npx hardhat omnichain Greeting name

The omnichain task accepts a contract name (Greeting) and a list of fields. The list of fields defines the values that will be included in the message passed to a universal app contract.

A field may have a type specified after the field name, separated by a colon. If no type is specified, the type defaults to string.

Supported types are: address, bool, bytes32, string, int,int8,int16,int128,int256,uint,uint8,uint16,uint128,uint256.

In this example, the message will contain only one value: name of type string.

The omnichain task has created:

  • contracts/Greeting.sol: a Solidity universal app contract
  • tasks/deploy.ts: a Hardhat task to deploy the contract
  • tasks/interact.ts: a Hardhat task to interact with the contract

It also modified hardhat.config.ts to import both deploy and interact tasks.

Universal App Contract

Let's review the contents of the Greeting contract:

contracts/Greeting.sol
// SPDX-License-Identifier: MIT
pragma solidity 0.8.7;
 
import "@zetachain/protocol-contracts/contracts/zevm/SystemContract.sol";
import "@zetachain/protocol-contracts/contracts/zevm/interfaces/zContract.sol";
import "@zetachain/toolkit/contracts/OnlySystem.sol";
 
contract Greeting is zContract, OnlySystem {
    SystemContract public systemContract;
 
    constructor(address systemContractAddress) {
        systemContract = SystemContract(systemContractAddress);
    }
 
    function onCrossChainCall(
        zContext calldata context,
        address zrc20,
        uint256 amount,
        bytes calldata message
    ) external virtual override onlySystem(systemContract) {
        (string memory name) = abi.decode(
            message,
            (string)
        );
        // TODO: implement the logic
    }
}

Greeting is a simple contract that inherits from the zContract interface (opens in a new tab).

The contract declares a state variable of type SystemContract that stores a reference to the system contract.

The constructor function accepts the address of the system contract and stores it in the systemContract state variable.

onCrossChainCall is a function that is called when the contract gets called by a token transfer transaction sent to the TSS address on a connected chain (when a gas token is deposited) or a deposit method call on the ERC-20 custody contract (when an ERC-20 token is deposited). 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 address that sent the token transfer transaction to the TSS address (triggering the omnichain contract) or the value passed to the deposit method call on the ERC-20 custody contract.
    • 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 transferred to the TSS address or an amount of tokens that were deposited to the ERC-20 custody contract.
  • message: the contents of the data field of the token transfer transaction.

The onCrossChainCall function should only be called by the system contract (in other words, by the ZetaChain protocol) to prevent a caller from supplying arbitrary values in context. The onlySystem modifier ensures that the function is called only as a response to a token transfer transaction sent to the TSS address or an ERC-20 custody contract.

By default, the onCrossChainCall function doesn't do anything else. You will implement the logic yourself based on your use case.

Modify the contract to emit an event after parsing the message:

contracts/Greeting.sol
// SPDX-License-Identifier: MIT
pragma solidity 0.8.7;
 
import "@zetachain/protocol-contracts/contracts/zevm/SystemContract.sol";
import "@zetachain/protocol-contracts/contracts/zevm/interfaces/zContract.sol";
import "@zetachain/toolkit/contracts/OnlySystem.sol";
 
contract Greeting is zContract, OnlySystem {
    SystemContract public systemContract;
    event Greet(string message);
 
    constructor(address systemContractAddress) {
        systemContract = SystemContract(systemContractAddress);
    }
 
    function onCrossChainCall(
        zContext calldata context,
        address zrc20,
        uint256 amount,
        bytes calldata message
    ) external virtual override onlySystem(systemContract) {
        (string memory name) = abi.decode(
            message,
            (string)
        );
        emit Greet(name);
    }
}

Deploy Task

The omnichain task has created a Hardhat task to deploy the contract:

tasks/deploy.ts
import { getAddress, ParamChainName } from "@zetachain/protocol-contracts";
import { task } from "hardhat/config";
import { HardhatRuntimeEnvironment } from "hardhat/types";
 
const main = async (args: any, hre: HardhatRuntimeEnvironment) => {
  const network = hre.network.name as ParamChainName;
 
  if (!/zeta_(testnet|mainnet)/.test(network)) {
    throw new Error('🚨 Please use either "zeta_testnet" or "zeta_mainnet" network to deploy to ZetaChain.');
  }
 
  const [signer] = await hre.ethers.getSigners();
  if (signer === undefined) {
    throw new Error(
      `Wallet not found. Please, run "npx hardhat account --save" or set PRIVATE_KEY env variable (for example, in a .env file)`
    );
  }
 
  const systemContract = getAddress("systemContract", network);
 
  const factory = await hre.ethers.getContractFactory(args.name);
  const contract = await factory.deploy(systemContract);
  await contract.deployed();
 
  const isTestnet = network === "zeta_testnet";
  const zetascan = isTestnet ? "athens.explorer" : "explorer";
  const blockscout = isTestnet ? "zetachain-athens-3" : "zetachain";
 
  if (args.json) {
    console.log(JSON.stringify(contract));
  } else {
    console.log(`🔑 Using account: ${signer.address}
 
🚀 Successfully deployed contract on ${network}.
📜 Contract address: ${contract.address}
🌍 ZetaScan: https://${zetascan}.zetachain.com/address/${contract.address}
🌍 Blockcsout: https://${blockscout}.blockscout.com/address/${contract.address}
`);
  }
};
 
task("deploy", "Deploy the contract", main)
  .addFlag("json", "Output in JSON")
  .addOptionalParam("name", "Contract to deploy", "Greeting");

Omnichain contracts are supposed to be deployed to ZetaChain, so the task checks that the --network flag value is always zeta_testnet.

The task uses the getAddress function from @zetachain/protocol-contracts to get the address of the system contract on ZetaChain.

The task then uses Ethers.js to deploy the contract to ZetaChain.

Interact Task

The omnichain task has also created a Hardhat task to interact with the contract:

tasks/interact.ts
import { task } from "hardhat/config";
import { HardhatRuntimeEnvironment } from "hardhat/types";
import { parseUnits } from "@ethersproject/units";
import { getAddress } from "@zetachain/protocol-contracts";
import ERC20Custody from "@zetachain/protocol-contracts/abi/evm/ERC20Custody.sol/ERC20Custody.json";
import { prepareData } from "@zetachain/toolkit/client";
import { utils, ethers } from "ethers";
import ERC20 from "@openzeppelin/contracts/build/contracts/ERC20.json";
 
const main = async (args: any, hre: HardhatRuntimeEnvironment) => {
  const [signer] = await hre.ethers.getSigners();
 
  const data = prepareData(args.contract, ["string"], [args.name]);
 
  let tx;
 
  if (args.token) {
    const custodyAddress = getAddress("erc20Custody", hre.network.name as any);
    if (!custodyAddress) {
      throw new Error(`No ERC20 Custody contract found for ${hre.network.name} network`);
    }
 
    const custodyContract = new ethers.Contract(custodyAddress, ERC20Custody.abi, signer);
    const tokenContract = new ethers.Contract(args.token, ERC20.abi, signer);
    const decimals = await tokenContract.decimals();
    const value = parseUnits(args.amount, decimals);
    const approve = await tokenContract.approve(custodyAddress, value);
    await approve.wait();
 
    tx = await custodyContract.deposit(signer.address, args.token, value, data);
    tx.wait();
  } else {
    const value = parseUnits(args.amount, 18);
    const to = getAddress("tss", hre.network.name as any);
    tx = await signer.sendTransaction({ data, to, value });
  }
 
  if (args.json) {
    console.log(JSON.stringify(tx, null, 2));
  } else {
    console.log(`🔑 Using account: ${signer.address}\n`);
 
    console.log(`🚀 Successfully broadcasted a token transfer transaction on ${hre.network.name} network.
📝 Transaction hash: ${tx.hash}
  `);
  }
};
 
task("interact", "Interact with the contract", main)
  .addParam("contract", "The address of the withdraw contract on ZetaChain")
  .addParam("amount", "Amount of tokens to send")
  .addOptionalParam("token", "The address of the token to send")
  .addFlag("json", "Output in JSON")
  .addParam("name");

The task uses the prepareData function from @zetachain/toolkit/helpers to prepare the data field of the token transfer transaction. prepareData accepts an omnichain contract address on ZetaChain, a list of argument types, and a list of argument names. The data field contains the following information:

  • the address of the contract on ZetaChain
  • the arguments to pass to the onCrossChainCall function in the message parameter

In the code generated above there are no arguments, so the data field is simply the address of the contract on ZetaChain.

Calling omnichain contracts is differs depending on whether a gas token is being deposited or an ERC-20 token.

If an ERC-20 token address is passed to the --token optional parameter, the interact task assumes you want to deposit an ERC-20 token in an omnichain contract.

To deposit an ERC-20 token into an omnichain contract you need to call the deposit method of the ERC-20 custody contract. The task first gets the address of the custody contract on the current network, creates an instance of a token contract, gets the number of decimals of the token, and approves the custody contract to spend the specified amount of ERC-20 tokens. The task then calls the deposit method of the custody contract, passing the following information:

  • signer.address: the sender address that will be available in the origin field of the context parameter of the onCrossChainCall function
  • args.token: the address of the ERC-20 token being deposited
  • value: the amount of tokens being deposited
  • data: the contents of the message

If the --token optional parameter is not used, the interact task assumes you want to deposit a gas token. To deposit a gas token you need to send a token transfer transaction to the TSS address on a connected chain.

getAddress retrieves the address of the TSS on the current network.

The task then uses Ethers.js to send a token transfer transaction to the TSS address. The transaction contains the following information:

  • data: the data field prepared by prepareData
  • to: the address of the TSS
  • value: the amount of tokens to transfer
  • value: the number of tokens to transfer

Deploy the Contract

Compile the contract:

npx hardhat compile --force

Use the --force flag to clear the cache and artifacts.

Deploy the contract to ZetaChain:

npx hardhat deploy --network zeta_testnet
🔑 Using account: 0x4955a3F38ff86ae92A914445099caa8eA2B9bA32

🚀 Successfully deployed contract on zeta_testnet.
📜 Contract address: 0x2C0201B9DFdC6Dcc23524Ab29c51c38dcc8afF54
🌍 ZetaScan: https://athens.explorer.zetachain.com/address/0x2C0201B9DFdC6Dcc23524Ab29c51c38dcc8afF54
🌍 Blockcsout: https://zetachain-athens-3.blockscout.com/address/0x2C0201B9DFdC6Dcc23524Ab29c51c38dcc8afF54

Interact with the Contract

Use the interact task to send a transaction on Ethereum Sepolia testnet:

npx hardhat interact --name alice --contract 0x2C0201B9DFdC6Dcc23524Ab29c51c38dcc8afF54 --network sepolia_testnet --amount 0
🔑 Using account: 0x2cD3D070aE1BD365909dD859d29F387AA96911e1

🚀 Successfully broadcasted a token transfer transaction on sepolia_testnet network.
📝 Transaction hash: 0x93b441dc2ddb751a60a2f4c0fc52dbbd447ed70eb962b1a01072328aa6872b73

The interact task has sent a transaction to the TSS address on Sepolia.

The data field of the transaction contains the following data:

0x2c0201b9dfdc6dcc23524ab29c51c38dcc8aff5400000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000005616c696365000000000000000000000000000000000000000000000000000000

The first 20 bytes is the universal app contract on ZetaChain, the rest are bytes that will be passed to the universal contract as message. In this example, the message is a string alice.

The value (amount) of the transaction is 0 because you're not sending any ETH in this example, you're just sending a message.

Track the transaction:

npx hardhat cctx 0x1d32177e6cdedbabd5f587ed0de80e8b4734636b329d88d24d015676257c330d
✓ CCTXs on ZetaChain found.

✓ 0xb672ea88abd2c35e4ee4a094969799259773a89e5d93906e46829632837106a5: 11155111 → 7001: OutboundMined (Remote omnichain contract call completed)

You can also track the cross-chain transaction on ZetaScan:

https://athens.explorer.zetachain.com/cc/tx/0xb672ea88abd2c35e4ee4a094969799259773a89e5d93906e46829632837106a5 (opens in a new tab)

Once the transaction is finalized on ZetaChain, you should be able to see the event emitted by the contract on ZetaChain. Go to the "Logs tab" to see:

Method id   efdeaaf5
Call        Greet(string message)
Name        Type                    Indexed?    Data
message     string                  false       alice

https://zetachain-athens-3.blockscout.com/address/0x2C0201B9DFdC6Dcc23524Ab29c51c38dcc8afF54 (opens in a new tab)

Congratulations! 🎉 You've successfully created a universal app contract, deployed it on ZetaChain and made a call to it with a message sent from Ethereum testnet. Now you know the basics of how to handle calls and parse messages from connected chains and can move on to build more exciting apps!