Cross-Chain ZETA Transfer
Overview
In this tutorial you will learn how to create a contract capable of sending ZETA tokens between contracts on connected chains using cross-chain messaging.
- This tutorial uses ZetaChain cross-chain messaging, but in this example we're only sending ZETA. You will learn how to send arbitrary data in a message in the Message tutorial.
- Since this contract's purpose is to send ZETA, the contract in this example will accept ZETA as input. Example contracts in other tutorials will use native gas assets as input for convenience.
- All the code will be generated for you by the template.
Prerequisites
- Node.js (opens in a new tab) (version 18 or above)
- Yarn (opens in a new tab)
- Git (opens in a new tab)
Set up your environment
git clone https://github.com/zeta-chain/template
cd template
yarn
Create the Contract
To create a new cross-chain messaging contract you will use the messaging
Hardhat task available by default in the template.
npx hardhat messaging CrossChainZETA --fees zeta
The messaging
task accepts one or more arguments: the name of the contract and
a list of arguments (optionally with types). The arguments define the contents
of the message that will be sent across chains. In this example the contract
will only be sending ZETA, so the list of arguments is empty.
Use the --fee zeta
to specify that this contract will be accepting ZETA
tokens. You will learn more about this flag later in this tutorial.
The messaging
task has created:
contracts/CrossChainMessage.sol
: a Solidity cross-chain messaging contracttasks/deploy.ts
: a Hardhat task to deploy the contract on one or more chainstasks/interact.ts
: a Hardhat task to interact with the contract
It also modified hardhat.config.ts
to import both deploy
and interact
tasks.
Let's review the contract:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.7;
import "@openzeppelin/contracts/interfaces/IERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@zetachain/protocol-contracts/contracts/evm/tools/ZetaInteractor.sol";
import "@zetachain/protocol-contracts/contracts/evm/interfaces/ZetaInterfaces.sol";
import "@zetachain/protocol-contracts/contracts/evm/Zeta.eth.sol";
contract CrossChainZeta is ZetaInteractor {
error ErrorTransferringZeta();
event CrossChainZetaEvent();
event CrossChainZetaRevertedEvent();
IERC20 internal immutable _zetaToken;
constructor(address connectorAddress, address zetaTokenAddress) ZetaInteractor(connectorAddress) {
_zetaToken = IERC20(zetaTokenAddress);
}
function sendMessage(uint256 destinationChainId, uint256 zetaValueAndGas) external payable {
if (!_isValidChainId(destinationChainId))
revert InvalidDestinationChainId();
bool success1 = _zetaToken.approve(address(connector), zetaValueAndGas);
bool success2 = _zetaToken.transferFrom(msg.sender, address(this), zetaValueAndGas);
if (!(success1 && success2)) revert ErrorTransferringZeta();
connector.send(
ZetaInterfaces.SendInput({
destinationChainId: destinationChainId,
destinationAddress: interactorsByChainId[destinationChainId],
destinationGasLimit: 300000,
message: abi.encode(),
zetaValueAndGas: zetaValueAndGas,
zetaParams: abi.encode("")
})
);
}
}
The contract:
- inherits from
ZetaInteractor
(opens in a new tab), which provides the connector functionality and helper functions like_isValidChainId
.
State Variables:
_zetaToken
: an internal immutable state variable that stores the address of the ZETA token contract.
The constructor passes connectorAddress
to the ZetaInteractor
constructor
and initializes the _zetaToken
state variables.
The sendMessage
function is used to send ZETA tokens to a recipient contract
on the destination chain. It first checks that the destination chain ID is
valid. Then it approves the contract to spend an amount of ZETA
Cross-Chain Fees
Cross-chain messaging fees are paid in ZETA tokens. This is convenient if the caller has ZETA tokens on the source chain. However, many users might only have native gas tokens. In this case it’s more convenient for a contract to accept native gas token, and swap it for ZETA.
To choose which token your contract accepts, use the --fees
flag.
To make the contract accept ZETA, use --fees zeta
. This value was used to
create a contract above. Since the contract's only purpose is to send ZETA, it
makes sense to also accept ZETA as a fee token.
To make the contract accept native gas tokens, use --fees native
or skip this
flag completely as this is the default option.
Sending ZETA
The sendMessage
function uses connector.send
to send a crosss-chain message
with the following arguments wrapped in a struct:
destinationChainId
: chain ID of the destination chaindestinationAddress
: address of the contract receiving the message on the destination chain (expressed in bytes since it can be non-EVM)destinationGasLimit
: gas limit for the destination chain's transactionmessage
: arbitrary message to be parsed by the receiving contract on the destination chain. In this example it’s empty as we're only sending ZETA, not arbitrary data.zetaValueAndGas
: amount of ZETA tokens to be sent to the destination chain, ZetaChain gas fees, and destination chain gas fees (expressed in ZETA tokens)zetaParams
: optional ZetaChain parameters. Currently, not being used.
After handling the fees the contract calls connector.send
to send
zetaValueAndGas
amount of ZETA to the destinationAddress
contract on the
destinationChainId
blockchain. The message is empty as only ZETA tokens are
being transferred.
Deploy Task
The messaging
task has created a Hardhat task to deploy the contract.
import { getAddress } from "@zetachain/protocol-contracts";
import { ethers } from "ethers";
import { task, types } from "hardhat/config";
import { HardhatRuntimeEnvironment } from "hardhat/types";
import type { ParamChainName } from "@zetachain/protocol-contracts";
const contractName = "CrossChainZeta";
const main = async (args: any, hre: HardhatRuntimeEnvironment) => {
const networks = args.networks.split(",");
const contracts: { [key: string]: string } = {};
await Promise.all(
networks.map(async (networkName: ParamChainName) => {
contracts[networkName] = await deployContract(hre, networkName, args.json, args.gasLimit);
})
);
for (const source in contracts) {
await setInteractors(hre, source as ParamChainName, contracts, args.json, args.gasLimit);
}
if (args.json) {
console.log(JSON.stringify(contracts, null, 2));
}
};
const initWallet = (hre: HardhatRuntimeEnvironment, networkName: ParamChainName) => {
const { url } = hre.config.networks[networkName] as any;
const provider = new ethers.providers.JsonRpcProvider(url);
const wallet = new ethers.Wallet(process.env.PRIVATE_KEY as string, provider);
return wallet;
};
const deployContract = async (
hre: HardhatRuntimeEnvironment,
networkName: ParamChainName,
json: boolean = false,
gasLimit: number
) => {
const wallet = initWallet(hre, networkName);
const connector = getAddress("connector", networkName);
const zetaToken = getAddress("zetaToken", networkName);
const { abi, bytecode } = await hre.artifacts.readArtifact(contractName);
const factory = new ethers.ContractFactory(abi, bytecode, wallet);
const contract = await factory.deploy(connector, zetaToken, { gasLimit });
await contract.deployed();
if (!json) {
console.log(`
🚀 Successfully deployed contract on ${networkName}
📜 Contract address: ${contract.address}`);
}
return contract.address;
};
const setInteractors = async (
hre: HardhatRuntimeEnvironment,
source: ParamChainName,
contracts: { [key: string]: string },
json: boolean = false,
gasLimit: number
) => {
if (!json) {
console.log(`
🔗 Setting interactors for a contract on ${source}`);
}
const wallet = initWallet(hre, source);
const { abi, bytecode } = await hre.artifacts.readArtifact(contractName);
const factory = new ethers.ContractFactory(abi, bytecode, wallet);
const contract = factory.attach(contracts[source]);
for (const counterparty in contracts) {
if (counterparty === source) continue;
const counterpartyContract = ethers.utils.solidityPack(["address"], [contracts[counterparty]]);
const chainId = hre.config.networks[counterparty].chainId;
await (
await contract.setInteractorByChainId(chainId, counterpartyContract, {
gasLimit,
})
).wait();
if (!json) {
console.log(`✅ Interactor address for ${chainId} (${counterparty}) is set to ${counterpartyContract}`);
}
}
};
task("deploy", "Deploy the contract", main)
.addParam("networks", "Comma separated list of networks to deploy to")
.addOptionalParam("gasLimit", "Gas limit", 10000000, types.int)
.addFlag("json", "Output JSON");
To establish cross-chain messaging between blockchains via ZetaChain, you need to deploy contracts capable of sending and receiving cross-chain messages to two or more blockchains connected to ZetaChain.
You can specify the desired chains by using a --networks
parameter of the
deploy
task, which accepts a list of network names separated by commas. For
instance, --networks sepolia_testnet,bsc_testnet
.
The main
function maintains a mapping of network names to their corresponding
deployed contract addresses, iterating over the networks to deploy the contract
on each one.
The contract's constructor requires three arguments: the connector contract's
address, the ZETA token's address, and the ZETA token consumer contract's
address. These addresses are obtained using ZetaChain's getAddress
.
The main
function subsequently sets interactors for each contract. An
interactor is a mapping between a chain ID of the destination and the contract
address on that chain.
When deploying to two chains (like Sepolia and BSC testnet), you will invoke
setInteractorByChainId
on a Sepolia contract and pass the BSC testnet chain ID
(97) and the BSC testnet contract address. You then perform the same operation
on a BSC testnet contract, passing the Sepolia chain ID (11155111) and the
Sepolia contract address. If deploying to more than two chains, you must call
setInteractorByChainId
for each link between the chains.
Interact Task
The messaging
task has also created a Hardhat task to interact with the
contract:
import { task } from "hardhat/config";
import { HardhatRuntimeEnvironment } from "hardhat/types";
import { parseEther } from "@ethersproject/units";
import { getAddress } from "@zetachain/protocol-contracts";
const main = async (args: any, hre: HardhatRuntimeEnvironment) => {
const [signer] = await hre.ethers.getSigners();
const factory = await hre.ethers.getContractFactory("CrossChainZeta");
const contract = factory.attach(args.contract);
const destination = hre.config.networks[args.destination]?.chainId;
if (destination === undefined) {
throw new Error(`${args.destination} is not a valid destination chain`);
}
const value = parseEther(args.amount);
const zetaTokenAddress = getAddress("zetaToken", hre.network.name as any);
const zetaFactory = await hre.ethers.getContractFactory("ZetaEth");
const zetaToken = zetaFactory.attach(zetaTokenAddress);
await (await zetaToken.approve(args.contract, value)).wait();
const tx = await contract.connect(signer).sendMessage(destination, value);
const receipt = await tx.wait();
if (args.json) {
console.log(JSON.stringify(tx, null, 2));
} else {
console.log(`🔑 Using account: ${signer.address}\n`);
console.log(`✅ The transaction has been broadcasted to ${hre.network.name}
📝 Transaction hash: ${receipt.transactionHash}
`);
}
};
task("interact", "Sends a message from one chain to another.", main)
.addFlag("json", "Output JSON")
.addParam("contract", "Contract address")
.addParam("amount", "Token amount to send")
.addParam("destination", "Destination chain");
The task accepts the following arguments:
contract
: address of the contract on the source chainamount
: amount of native tokens to send with the transactiondestination
: name of the destination chain
The main
function uses the contract
argument to attach to the contract on
the source chain. It then uses the destination
argument to obtain the
destination chain's chain ID. The function subsequently calls the sendMessage
contract method passing destination
and value
.
Deploy the Contract
Clear the cache and artifacts, then compile the contract:
npx hardhat compile --force
Run the following command to deploy the contract to two networks:
npx hardhat deploy --networks sepolia_testnet,bsc_testnet
🚀 Successfully deployed contract on bsc_testnet
📜 Contract address: 0x65D661B68ff1466dedf80685450ac4c684b522BB
🚀 Successfully deployed contract on sepolia_testnet
📜 Contract address: 0xDEB42ce9d2F32caaA38Bf3107a054951E11575DF
🔗 Setting interactors for a contract on bsc_testnet
✅ Interactor address for 11155111 (sepolia_testnet) is set to 0xdeb42ce9d2f32caaa38bf3107a054951e11575df
🔗 Setting interactors for a contract on sepolia_testnet
✅ Interactor address for 97 (bsc_testnet) is set to 0x65d661b68ff1466dedf80685450ac4c684b522bb
Send ZETA
Run the following command to send ZETA tokens from Sepolia to BSC testnet.
Please, note that since the contract expect ZETA tokens as fees, the value of
the --amount
param is denominated in ZETA tokens. A fraction of the amount
will be deducted as a cross-chain fee, the rest will be sent to the recipient on
the destination chain.
npx hardhat interact --contract 0xDEB42ce9d2F32caaA38Bf3107a054951E11575DF --network sepolia_testnet --amount 3 --destination bsc_testnet
🔑 Using account: 0x4955a3F38ff86ae92A914445099caa8eA2B9bA32
✅ The transaction has been broadcasted to sepolia_testnet
📝 Transaction hash: 0x90db3fa9e6e169eadae84b2dbfac15c6829807029f7198f1fcf515e6fb4d04cb
You can check the broadcasted transaction on Sepolia's Etherscan:
Next, you can track the progress of the cross-chain transaction:
npx hardhat cctx 0x90db3fa9e6e169eadae84b2dbfac15c6829807029f7198f1fcf515e6fb4d04cb
✓ CCTXs on ZetaChain found.
✓ 0xbf258794df4475fc79fec38f9afe3a92bee82477c3af1c9e61912e6b21408bb0: 11155111 → 97: OutboundMined
Source Code
You can find the source code for the example in this tutorial here:
https://github.com/zeta-chain/example-contracts/tree/main/messaging/zeta (opens in a new tab)
Continue Learning
Continue with the next part or try a related tutorial