Overview
If you already read the previous tutorials you already know how to use ZetaChain's EVM. A very common use case on ZetaChain's EVM is a smart contract with a single input from one chain, perform some logic, and then execute the output to another or multiple chains.
The example in this tutorial does exactly that: the contract reads an address from the message, and then send some tokens to that address in several chains.
This capability may be useful for applications like multichain asset managers or DeFi applications that need to distribute or manage assets on many chains from one place.
This tutorial also demonstrates how a single inbound cross-chain transactions can result in more than one outbound cross-chain transactions.
Set up your environment
git clone https://github.com/zeta-chain/template
Create the contract
Run the following command to create a new omnichain contract called
MultiOutput
with one parameter in the message:
npx hardhat omnichain Multioutput recipient btcRecipient targetToken
OnCrossChainCall
Implement the onCrossChainCall
function:
// 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/BytesHelperLib.sol";
import "@zetachain/toolkit/contracts/SwapHelperLib.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@zetachain/toolkit/contracts/OnlySystem.sol";
contract Multioutput is zContract, Ownable, OnlySystem {
error NoAvailableTransfers();
error InvalidRecipient();
error FetchingBTCZRC20Failed();
event Withdrawal(address, uint256, bytes);
SystemContract public systemContract;
uint256 constant BITCOIN = 18332;
constructor(address systemContractAddress) {
systemContract = SystemContract(systemContractAddress);
}
function onCrossChainCall(
zContext calldata context,
address zrc20,
uint256 amount,
bytes calldata message
) external virtual override onlySystem(systemContract) {
if (systemContract.gasCoinZRC20ByChainId(BITCOIN) == address(0))
revert FetchingBTCZRC20Failed();
(
address evmRecipient,
bytes memory btcRecipient,
address[] memory destinationTokens
) = parseMessage(context.chainID, message);
uint256 totalTransfers = destinationTokens.length;
if (totalTransfers == 0) revert NoAvailableTransfers();
uint256 amountToTransfer = amount / totalTransfers;
uint256 leftOver = amount - amountToTransfer * totalTransfers;
uint256 lastTransferIndex = destinationTokens[
destinationTokens.length - 1
] == zrc20
? destinationTokens.length - 2
: destinationTokens.length - 1;
for (uint256 i; i < destinationTokens.length; i++) {
address targetZRC20 = destinationTokens[i];
if (targetZRC20 == zrc20) continue;
if (lastTransferIndex == i) {
amountToTransfer += leftOver;
}
bytes memory recipient = abi.encodePacked(
BytesHelperLib.addressToBytes(evmRecipient)
);
if (targetZRC20 == systemContract.gasCoinZRC20ByChainId(BITCOIN)) {
if (btcRecipient.length == 0) revert InvalidRecipient();
recipient = abi.encodePacked(btcRecipient);
}
_doSwapAndWithdraw(zrc20, amountToTransfer, targetZRC20, recipient);
}
}
}
On a high level the onCrossChainCall
function does the following:
- Parse the message to get the recipient address and the destination tokens.
- Calculate the amount to transfer to each destination token.
- Loop through the destination tokens and call the
_doSwapAndWithdraw
function to perform the transfer.
When looping through the destination tokens, the contract checks that the destination token is not the same as the source token, and if it is, it skips the transfer.
The contract also checks if the destination token is BTC, and if it is, it uses the BTC recipient address from the message.
Parse the message
Next, implement the parseMessage
function:
function parseMessage(
uint256 chainID,
bytes calldata message
) public pure returns (address, bytes memory, address[] memory) {
address evmRecipient;
bytes memory btcRecipient;
address[] memory destinationTokens;
if (chainID == BITCOIN) {
evmRecipient = BytesHelperLib.bytesToAddress(message, 0);
uint256 numTokens = message.length / 20 - 1;
destinationTokens = new address[](numTokens);
for (uint256 i = 0; i < numTokens; i++) {
destinationTokens[i] = BytesHelperLib.bytesToAddress(
message,
20 + i * 20
);
}
} else {
(
address evmAddress,
bytes memory btcAddress,
bytes memory targetTokens
) = abi.decode(message, (address, bytes, bytes));
btcRecipient = btcAddress;
evmRecipient = evmAddress;
uint256 numTokens = targetTokens.length / 32;
destinationTokens = new address[](numTokens);
for (uint256 i = 0; i < numTokens; i++) {
destinationTokens[i] = BytesHelperLib.bytesMemoryToAddress(
targetTokens,
i * 32
);
}
}
return (evmRecipient, btcRecipient, destinationTokens);
}
parseMessage
is a helper function that decodes the message to get the
recipient address and the destination tokens.
If the source chain is Bitcoin, the function assumes that the destination address is an EVM address and uses the helper function to manually decode the first 20 bytes as the recipient address. The rest of the message is a list of destination tokens. The function loops through the message and decodes each 20 bytes as an address.
If the source chain is not Bitcoin, the function expects both an EVM address and
a BTC address in the message. The function decodes the message using the
abi.decode
function and returns the recipient address, the BTC recipient
address, and the destination tokens. The function loops through the message and
decodes token addresses.
Swap and Withdraw
Finally, implement the _doSwapAndWithdraw
function:
function _doSwapAndWithdraw(
address zrc20,
uint256 amountToTransfer,
address targetZRC20,
bytes memory recipient
) internal {
(address gasZRC20, uint256 gasFee) = IZRC20(targetZRC20)
.withdrawGasFee();
uint256 inputForGas = SwapHelperLib.swapTokensForExactTokens(
systemContract,
zrc20,
gasFee,
gasZRC20,
amountToTransfer
);
uint256 outputAmount = SwapHelperLib.swapExactTokensForTokens(
systemContract,
zrc20,
amountToTransfer - inputForGas,
targetZRC20,
0
);
IZRC20(gasZRC20).approve(targetZRC20, gasFee);
IZRC20(targetZRC20).withdraw(recipient, outputAmount);
}
_doSwapAndWithdraw
mirrors the functionality of the omnichain swap example
contract.
It first calls the withdrawGasFee
function to get the gas token and the gas
fee.
If targetZRC20
is an ERC-20 token, gasZRC20
will not be the same as
targetZRC20
and itβs important to have two swaps: the first swap is to swap
the source token for the gas token, and the second swap is to swap the rest of
the source token amount for the target token.
If targetZRC20
is a gas token, gasZRC20
will be the same as targetZRC20
.
We could skip the first swap and just swap the source token for the target
token, but for the sake of simplicity, we use the same logic for both cases.
Modify the Interact Task
Modify the interact task to correctly handle BTC destination address and a list of destination token addresses. If BTC address is provided, it gets converted into bytes. Destination tokens are split into an array and encoded as addresses.
const main = async (args: any, hre: HardhatRuntimeEnvironment) => {
const [signer] = await hre.ethers.getSigners();
const destinationTokens = args.targetToken.split(",");
let bitcoinAddress = "";
let data;
if (args.btcRecipient) {
bitcoinAddress = args.btcRecipient;
}
const bitcoinAddressBytes = utils.solidityPack(["bytes"], [utils.toUtf8Bytes(bitcoinAddress)]);
const tokensBytes = ethers.utils.concat(
destinationTokens.map((address) => utils.defaultAbiCoder.encode(["address"], [address]))
);
data = prepareData(args.contract, ["address", "bytes", "bytes"], [args.recipient, bitcoinAddressBytes, tokensBytes]);
//...
};
task("interact", "Interact with the contract", main).addOptionalParam("btcRecipient", "The bitcoin address to send to");
Create an Account and Request Tokens from the Faucet
Before proceeding with the next steps, make sure you have created an account and requested ZETA tokens from the faucet.
Deploy the Contract
Clear the cache and artifacts, then compile the contract:
npx hardhat compile --force
npx hardhat deploy --network zeta_testnet
π Using account: 0x4955a3F38ff86ae92A914445099caa8eA2B9bA32
π Successfully deployed contract on ZetaChain.
π Contract address: 0xa573Df1F0729FE6F1BD69b0a5dbFE393e6e09f47
π Explorer: https://athens3.explorer.zetachain.com/address/0xa573Df1F0729FE6F1BD69b0a5dbFE393e6e09f47
Interact with the Contract
npx hardhat interact --contract 0xa573Df1F0729FE6F1BD69b0a5dbFE393e6e09f47 --network bsc_testnet --target-token 0x05BA149A7bd6dC1F937fA9046A9e05C05f3b18b0,0x65a45c57636f9BcCeD4fe193A602008578BcA90b --amount 5 --recipient 0x4955a3F38ff86ae92A914445099caa8eA2B9bA32 --btc-recipient tb1q8shzf7afc3rhw8n6w6ec32s8h6e2mrw077d0gg
π Using account: 0x4955a3F38ff86ae92A914445099caa8eA2B9bA32
π Successfully broadcasted a token transfer transaction on bsc_testnet network.
π Transaction hash: 0xf6895678629b99d756c8d4378d4d3e57e495c95edd39b2ab51cbd707bff75d59
npx hardhat cctx 0xf6895678629b99d756c8d4378d4d3e57e495c95edd39b2ab51cbd707bff75d59
β CCTXs on ZetaChain found.
β 0x83dc34b68d36fbafb7a9c7eef51f3a19a5b842a39f1364f53091a236266d4ff1: 97 β 7001: OutboundMined (Remote omnichain contract call completed)
β 0x25494fd66075c98a1ef1d13723fb0ca9029377580cf1c2847d0b786896d850cb: 7001 β 11155111: PendingOutbound β OutboundMined
β 0xacc9d061b051c48b79b1dfec9e2ed3b7679af7ac225e8c995fab77b96d437bcc: 7001 β 18332: PendingOutbound β 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/omnichain/multioutput (opens in a new tab)