In this tutorial you will create a cross-chain swap contract. This contract will enable users to exchange a native gas token or a supported ERC-20 token from one connected blockchain for a token on another blockchain. For example, a user will be able to swap USDC from Ethereum to BTC on Bitcoin in a single transaction.

This tutorial features architecture that is compatible with the current ZetaChain testnet, but will be phased out once the gateway is released. For a gateway compatible localnet-only example check out Swap on Localnet

You will learn how to:

  • Decode incoming messages from both EVM chains and Bitcoin.
  • Work with the ZRC-20 representation of tokens transferred from connected chains.
  • Use the swap helper function to swap tokens using Uniswap v2 pools.
  • Withdraw ZRC-20 tokens to a connected chain, accounting for cross-chain gas fees.

The swap contract will be implemented as a universal app and deployed on ZetaChain.

Universal apps can accept token transfers and contract calls from connected chains. Tokens transferred from connected chains to a universal app contract are represented as ZRC-20. For example, ETH transferred from Ethereum is represented as ZRC-20 ETH. ZRC-20 tokens have the unique property of being able to be withdrawn back to their original chain as native assets.

The swap contract will:

  • Accept a contract call from a connected chain containing native gas or supported ERC-20 tokens and a message.
  • Decode the message, which should include:
    • Target token address (represented as ZRC-20)
    • Recipient address on the destination chain
  • Query withdraw gas fee of the target token.
  • Swap a fraction of the input token for a ZRC-20 gas tokens to cover the withdrawal fee using the Uniswap v2 liquidity pools.
  • Swap the remaining input token amount for the target token ZRC-20.
  • Withdraw ZRC-20 tokens to the destination chain

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.

Run the following command to create a new universal omnichain contract called Swap with two values in the message: target token address and recipient.

npx hardhat omnichain Swap targetToken:address recipient
contracts/Swap.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/SwapHelperLib.sol";
import "@zetachain/toolkit/contracts/BytesHelperLib.sol";
import "@zetachain/toolkit/contracts/OnlySystem.sol";
 
contract Swap is zContract, OnlySystem {
    SystemContract public systemContract;
    uint256 constant BITCOIN = 18332;
 
    constructor(address systemContractAddress) {
        systemContract = SystemContract(systemContractAddress);
    }
 
    struct Params {
        address target;
        bytes to;
    }
 
    function onCrossChainCall(
        zContext calldata context,
        address zrc20,
        uint256 amount,
        bytes calldata message
    ) external virtual override onlySystem(systemContract) {
        Params memory params = Params({target: address(0), to: bytes("")});
 
        if (context.chainID == BITCOIN) {
            params.target = BytesHelperLib.bytesToAddress(message, 0);
            params.to = abi.encodePacked(
                BytesHelperLib.bytesToAddress(message, 20)
            );
        } else {
            (address targetToken, bytes memory recipient) = abi.decode(
                message,
                (address, bytes)
            );
            params.target = targetToken;
            params.to = recipient;
        }
 
        swapAndWithdraw(zrc20, amount, params.target, params.to);
    }
 
    function swapAndWithdraw(
        address inputToken,
        uint256 amount,
        address targetToken,
        bytes memory recipient
    ) internal {
        uint256 inputForGas;
        address gasZRC20;
        uint256 gasFee;
 
        (gasZRC20, gasFee) = IZRC20(targetToken).withdrawGasFee();
 
        inputForGas = SwapHelperLib.swapTokensForExactTokens(
            systemContract,
            inputToken,
            gasFee,
            gasZRC20,
            amount
        );
 
        uint256 outputAmount = SwapHelperLib.swapExactTokensForTokens(
            systemContract,
            inputToken,
            amount - inputForGas,
            targetToken,
            0
        );
 
        IZRC20(gasZRC20).approve(targetToken, gasFee);
        IZRC20(targetToken).withdraw(recipient, outputAmount);
    }
}

Decoding the Message

Create a Params struct, which will hold two values:

  • address target: target token ZRC-20 address.
  • bytes to: recipient address on the destination chain. We're using bytes, because the recipient can be either on EVM (like Ethereum or BNB) or on Bitcoin.

First, decode the incoming message to get the parameter values. The message might be encoded differently depending on the source chain. For example, on Bitcoin there is a upper limit of 80 bytes, so you might want to encode the message in the most efficient way possible. On EVM don't have this limit, so it's fine to use abi.encode to encode the message.

Use context.chainID to determine the connected chain from which the contract is called.

If it's Bitcoin, the first 20 bytes of the message are the params.target encoded as an address. Use bytesToAddress helper method to get the target token address. To get the recipient address, use the same helper method with an offset of 20 bytes and then use abi.encodePacked to convert the address to bytes.

If it's an EVM chain, use abi.decode to decode the message into the params.target and params.to.

Swap and Withdraw Function

Swapping for Gas Token

Create a new function called swapAndWithdraw. Use the withdrawGasFee method of the target token ZRC-20 to get the gas fee token address and the gas fee amount. If the target token is the gas token of the destination chain (for example, BNB), gasZRC20 will be the same params.target. However, if the target token is an ERC-20, like USDC on BNB, gasZRC20 will tell you the address of the ZRC-20 of the destination chain.

Use the swapTokensForExactTokens helper method to swap the incoming token for the gas coin using the internal liquidity pools. The method returns the amount of the incoming token that was used to pay for the gas.

Swapping for Target Token

Next, swap the incoming amount minus the amount spent swapping for a gas fee for the target token on the destination chain using the swapExactTokensForTokens helper method.

Withdraw Target Token to Connected Chain

At this point the contract has the required gasFee amount of gasZRC20 token of the connected chain and an outputAmount amount of params.target token.

To withdraw tokens to a connected chain you will be calling the withdraw method of ZRC-20. The withdraw method expects the caller (in our case the contract) to have the required amount of gas tokens ZRC-20. Approve the target token ZRC-20 contract to spend the gasFee amount. Finally, call the withdraw method of the target token ZRC-20 to send the tokens to the recipient on the connected chain.

Note that you don't have to tell which chain to withdraw to because each ZRC-20 contract knows which connected chain it is associated with. For example, ZRC-20 Ethereum USDC can only be withdrawn to Ethereum.

In the interact task generated for us by the contract template the recipient is encoded as string. Our contract, however, expects the recipient to be encoded as bytes to ensure that both EVM and Bitcoin addresses are supported.

To support both EVM and Bitcoin addresses, we need to check if the recipient is a valid Bitcoin address. If it is, we need to encode it as bytes using utils.solidityPack.

If it’s not a valid bech32 address, then we assume it's an EVM address and use args.recipient as the value for the recipient.

Finally, update the prepareData function call to use the bytes type for the recipient.

tasks/interact.ts
import bech32 from "bech32";
 
const main = async (args: any, hre: HardhatRuntimeEnvironment) => {
  const [signer] = await hre.ethers.getSigners();
 
  let recipient;
  try {
    if (bech32.decode(args.recipient)) {
      recipient = utils.solidityPack(["bytes"], [utils.toUtf8Bytes(args.recipient)]);
    }
  } catch (e) {
    recipient = args.recipient;
  }
 
  const data = prepareData(args.contract, ["address", "bytes"], [args.targetToken, recipient]);
  //...
};

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

npx hardhat compile --force
npx hardhat deploy --network zeta_testnet
πŸ”‘ Using account: 0x2cD3D070aE1BD365909dD859d29F387AA96911e1

πŸš€ Successfully deployed contract on ZetaChain.
πŸ“œ Contract address: 0xf6CDd83AB44E4d947FE52c2637ee4A04F330328E
🌍 Explorer: https://athens3.explorer.zetachain.com/address/0xf6CDd83AB44E4d947FE52c2637ee4A04F330328E

Use the interact task to perform a cross-chain swap. In this example, we're swapping native sETH from Sepolia for BNB on BNB chain. The contract will deposit sETH to ZetaChain as ZRC-20, swap it for ZRC-20 BNB and then withdraw native BNB to the BNB chain. To get the value of the --target-token find the ZRC-20 contract address of the destination token in the ZRC-20 section of the docs.

npx hardhat interact --contract 0x175DeE06ca605674e49F1FADfC6B399D6ab31726 --amount 0.3 --network sepolia_testnet --target-token 0xd97B1de3619ed2c6BEb3860147E30cA8A7dC9891 --recipient 0x4955a3F38ff86ae92A914445099caa8eA2B9bA32
πŸ”‘ Using account: 0x4955a3F38ff86ae92A914445099caa8eA2B9bA32

πŸš€ Successfully broadcasted a token transfer transaction on sepolia_testnet
network. πŸ“ Transaction hash:
0xc4b2bbd3b3090e14797463af1965a00318cc39a50fce53a5d5856d09fe67410d

Track your cross-chain transaction:

npx hardhat cctx
0xc4b2bbd3b3090e14797463af1965a00318cc39a50fce53a5d5856d09fe67410d
βœ“ CCTXs on ZetaChain found.

βœ“ 0xf6419c8d850314a436a3cfc7bc5cd487e29bad9c8fae0d8be9a913d622599980: 11155111 β†’ 7001: OutboundMined (Remote omnich
ain contract call completed)
β § 0x5e533d781ddc9760784ba9c1887f77a80d3ca0d771ea41f02bc4d0a1c9412dc2: 7001 β†’ 97: PendingOutbound (ZRC20 withdrawal
event setting to pending outbound directly)

Now let's swap USDC from Sepolia to BNB on BNB chain. To send USDC specify the ERC-20 token contract address (on Sepolia) in the --token parameter. You can find the address of the token in the ZRC-20 section of the docs.

npx hardhat interact --contract 0xf6CDd83AB44E4d947FE52c2637ee4A04F330328E --amount 5 --token 0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238 --network sepolia_testnet --target-token 0xd97B1de3619ed2c6BEb3860147E30cA8A7dC9891 --recipient 0x4955a3F38ff86ae92A914445099caa8eA2B9bA32
πŸ”‘ Using account: 0x2cD3D070aE1BD365909dD859d29F387AA96911e1

πŸš€ Successfully broadcasted a token transfer transaction on sepolia_testnet network.
πŸ“ Transaction hash: 0xce8832232639d29d40078e14d0a5b20c055123d6df1e1d39f90cfd130c33466d
npx hardhat cctx 0xce8832232639d29d40078e14d0a5b20c055123d6df1e1d39f90cfd130c33466d
βœ“ CCTXs on ZetaChain found.

βœ“ 0x1ae1436358ef755c1c782d0a249ae99e857b0aecb91dcd8da4a4e7171f5d9459: 11155111 β†’ 7001: OutboundMined (Remote omnichain contract call completed)
βœ“ 0xbefe99d3e17d16fc88762f85b1becd1396b01956c04b5ec037abc2c63d821caa: 7001 β†’ 97: OutboundMined (ZRC20 withdrawal event setting to pending outbound directly : Outbound succeeded, mined)

Use the send-btc task to send Bitcoin to the TSS address with a memo. The memo should contain the following:

  • Omnichain contract address on ZetaChain: 175DeE06ca605674e49F1FADfC6B399D6ab31726
  • Target token address: 05BA149A7bd6dC1F937fA9046A9e05C05f3b18b0
  • Recipient address: 4955a3F38ff86ae92A914445099caa8eA2B9bA32
npx hardhat send-btc --amount 0.001 --memo 175DeE06ca605674e49F1FADfC6B399D6ab3172605BA149A7bd6dC1F937fA9046A9e05C05f3b18b04955a3F38ff86ae92A914445099caa8eA2B9bA32 --recipient tb1qy9pqmk2pd9sv63g27jt8r657wy0d9ueeh0nqur
npx hardhat cctx 29d6a0af11aa6164e83c17d9f129e4ec504d327fb94429732d95c16ddfcce999

You can find the source code for the example in this tutorial here:

https://github.com/zeta-chain/example-contracts/tree/main/omnichain/swap (opens in a new tab)