Single Input, Multiple Outputs

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.

git clone

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

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(
            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:

  1. Parse the message to get the recipient address and the destination tokens.
  2. Calculate the amount to transfer to each destination token.
  3. 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.

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(
                    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(
                    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.

Finally, implement the _doSwapAndWithdraw function:

    function _doSwapAndWithdraw(
        address zrc20,
        uint256 amountToTransfer,
        address targetZRC20,
        bytes memory recipient
    ) internal {
        (address gasZRC20, uint256 gasFee) = IZRC20(targetZRC20)
        uint256 inputForGas = SwapHelperLib.swapTokensForExactTokens(
        uint256 outputAmount = SwapHelperLib.swapExactTokensForTokens(
            amountToTransfer - inputForGas,
        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 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( => 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");

Before proceeding with the next steps, make sure you have created an account and requested ZETA tokens from the faucet.

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:
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

You can find the source code for the example in this tutorial here: (opens in a new tab)