Swap Any Token

In the previous Swap tutorial, you learned how to create universal swap contracts that enable users to exchange tokens from one connected blockchain for a token on another blockchain, with the target token always withdrawn to the destination chain.

In this tutorial, you will enhance the swap contract to support swapping tokens to any token (such as ZRC-20, ERC-20, or ZETA) and provide the flexibility to either withdraw the token to the destination chain or keep it on ZetaChain.

Keeping swapped tokens on ZetaChain is useful if you want to use ZRC-20 in non-universal contracts that don't yet have the capacity to accept tokens from connected chains directly, or if the destination token is ZETA, which you want to keep on ZetaChain.

Copy the existing swap example into a new file SwapToAnyToken.sol and make the necessary changes:

// 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/protocol-contracts/contracts/zevm/interfaces/IWZETA.sol";
import "@zetachain/toolkit/contracts/OnlySystem.sol";
contract SwapToAnyToken is zContract, OnlySystem {
    SystemContract public systemContract;
    uint256 constant BITCOIN = 18332;
    constructor(address systemContractAddress) {
        systemContract = SystemContract(systemContractAddress);
    struct Params {
        address target;
        bytes to;
        bool withdraw;
    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(""),
            withdraw: true
        if (context.chainID == BITCOIN) {
   = BytesHelperLib.bytesToAddress(message, 0);
   = abi.encodePacked(BytesHelperLib.bytesToAddress(message, 20));
            if (message.length >= 41) {
                params.withdraw = BytesHelperLib.bytesToBool(message, 40);
        } else {
            (address targetToken, bytes memory recipient, bool withdrawFlag) = abi.decode(
                (address, bytes, bool)
   = targetToken;
   = recipient;
            params.withdraw = withdrawFlag;
        uint256 inputForGas;
        address gasZRC20;
        uint256 gasFee;
        if (params.withdraw) {
            (gasZRC20, gasFee) = IZRC20(;
            inputForGas = SwapHelperLib.swapTokensForExactTokens(
        uint256 outputAmount = SwapHelperLib.swapExactTokensForTokens(
            params.withdraw ? amount - inputForGas : amount,
        if (params.withdraw) {
            IZRC20(gasZRC20).approve(, gasFee);
            IZRC20(, outputAmount);
        } else {
            IWETH9(, outputAmount);

Add the WZETA interface import, which allows interacting with ERC-20 compatible tokens (such as ZRC-20) as well as unwrapping WZETA into native ZETA.

Change the contract name from Swap to SwapToAnyToken.

Add bool withdraw to the Params struct. This value determines if a user wants to withdraw the swapped token to the destination chain (true) or keep the token on ZetaChain (false). By default the value will be set to true.

If the contract is being called from Bitcoin, use bytesToBool to decode the last value in the message, and set it as the value of params.withdraw.

If the contract is being called from an EVM chain, use abi.decode to decode all values: target token, recipient and withdraw.

If a user wants to withdraw the tokens, query the gas token and the gas fee. Since a user now has an option to not withdraw, this step has become optional.

Modify the amount passed to swapExactTokensForTokens. If a user withdraws token, subtract the withdraw fee in input token amount.

Finally, add a conditional to either withdraw ZRC-20 tokens to a connnected chain or transfer the target token to the recipient on ZetaChain.

let withdraw = true;
if (args.withdraw != undefined) {
  withdraw = JSON.parse(args.withdraw);
const data = prepareData(args.contract, ["address", "bytes", "bool"], [args.targetToken, recipient, withdraw]);

Add an optional parameter withdraw, which determines if a user wants to withdraw the target token to the destination chain. By default set the value to true, and pass withdraw as the third value in the message.

npx hardhat compile --force

When deploying use the --name flag to specify which contract you want to deploy:

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

🚀 Successfully deployed contract on zeta_testnet.
📜 Contract address: 0x48D512595699A1c1c40C7B5Fc378512Ab0dCFAd7
🌍 ZetaScan:
🌍 Blockcsout:

When using the interact task specify ZETA token contract address as the value of --target-token:

npx hardhat interact --contract 0x1767A93A96D339EeC8E0325D94B5d3E4454d542f --network bsc_testnet --amount 0.01 --target-token 0xcC683A782f4B30c138787CB5576a86AF66fdc31d --recipient 0x6093537Aa6C8C8bf4705bda40aC9193977208B39 --withdraw false

Track the transaction:

npx hardhat cctx 0x3f22de7a6d6669ce55ce2a95adaee46b8fd8a751b145c903c62300f9e7e44e4d
✓ CCTXs on ZetaChain found.

✓ 0x7f7f7051dd9da2037b7fc01c43b18649b923c522e9ff3934e28647da59fffe79: 97 → 7001: OutboundMined (Remote omnichain contract call completed)

Notice that an outbound CCTX is not created, because when swapping without withdrawing, target tokens remain on ZetaChain and are not withdrawn.

npx hardhat interact --contract 0x1767A93A96D339EeC8E0325D94B5d3E4454d542f --network bsc_testnet --amount 0.1 --target-token 0xcC683A782f4B30c138787CB5576a86AF66fdc31d --recipient 0x6093537Aa6C8C8bf4705bda40aC9193977208B39
npx hardhat cctx 0x8323982f778ab14a5c37b10a80c5837da74ccdc8dba6ab4368f8b00612da8e1e
✓ CCTXs on ZetaChain found.

✓ 0x86c66514209a23499d23fea4c2d7177e87bff5199d273a3592fb685d7d945da8: 97 → 7001: OutboundMined (Remote omnichain contract call completed)
✓ 0x4a1d7df81e11c4273c0d105a23f049409d06f0bbc03e286014276adb247e208f: 7001 → 11155111: OutboundMined (ZRC20 withdrawal event setting to pending outbound directly : Outbound succeeded, mined)