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

contracts/SwapToAnyToken.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/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;
    }
 
    receive() external payable {}
 
    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) {
            params.target = BytesHelperLib.bytesToAddress(message, 0);
            params.to = 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(message, (address, bytes, bool));
            params.target = targetToken;
            params.to = recipient;
            params.withdraw = withdrawFlag;
        }
 
        swapAndWithdraw(
            zrc20,
            amount,
            params.target,
            params.to,
            params.withdraw
        );
    }
 
    function swapAndWithdraw(
        address inputToken,
        uint256 amount,
        address targetToken,
        bytes memory recipient,
        bool withdraw
    ) internal {
        uint256 inputForGas;
        address gasZRC20;
        uint256 gasFee;
 
        if (withdraw) {
            (gasZRC20, gasFee) = IZRC20(targetToken).withdrawGasFee();
 
            inputForGas = SwapHelperLib.swapTokensForExactTokens(
                systemContract,
                inputToken,
                gasFee,
                gasZRC20,
                amount
            );
        }
 
        uint256 outputAmount = SwapHelperLib.swapExactTokensForTokens(
            systemContract,
            inputToken,
            withdraw ? amount - inputForGas : amount,
            targetToken,
            0
        );
 
        if (withdraw) {
            IZRC20(gasZRC20).approve(targetToken, gasFee);
            IZRC20(targetToken).withdraw(recipient, outputAmount);
        } else {
            address wzeta = systemContract.wZetaContractAddress();
            if (targetToken == wzeta) {
                IWETH9(wzeta).withdraw(outputAmount);
                address payable recipientAddress = payable(
                    address(uint160(bytes20(recipient)))
                );
                recipientAddress.transfer(outputAmount);
            } else {
                address recipientAddress = address(uint160(bytes20(recipient)));
                IWETH9(targetToken).transfer(recipientAddress, outputAmount);
            }
        }
    }
 
    function swap(
        address inputToken,
        uint256 amount,
        address targetToken,
        bytes memory recipient,
        bool withdraw
    ) public {
        IZRC20(inputToken).transferFrom(msg.sender, address(this), amount);
 
        swapAndWithdraw(inputToken, amount, targetToken, recipient, withdraw);
    }
}

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 the withdraw flag.

Next, add params.withdraw as the last argument to the swapAndWithdraw function call.

Swap and Withdraw Function

Add bool withdraw as the last parameter to the function definition.

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 connected chain or transfer the target token to the recipient on ZetaChain. If a user doesn't want to withdraw a token you need to consider two scenarios:

  • If the target token is WZETA, unwrap it and transfer native ZETA to the recipient.
  • If the target token is not WZETA, transfer it to the recipient as any other ERC-20-compatible token.

Swap Function

Create a new public swap function to make it possible for users to call the "swap and withdraw" function. Compared to "swap and withdraw", which is internal and is not meant to be called directly, the "swap" function is public and is meant to be called from ZetaChain. The purpose of "swap" is to allow users to swap tokens they have on ZetaChain for other tokens and optionally also withdraw them. For example, when a user has a ZRC-20 ETH and they want to swap it for ZRC-20 BTC (without withdrawing), or swap it for ZRC-20 BNB and withdraw it to the BNB chain as a native BNB token.

tasks/interact.ts
let withdraw = true;
if (args.withdraw != undefined) {
  withdraw = JSON.parse(args.withdraw);
}
 
const data = prepareData(args.contract, ["address", "bytes", "bool"], [args.targetToken, recipient, withdraw]);
 
//...
  .addOptionalParam("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.

While the interact task is meant to be called on a connected chain to trigger a universal contract, the swap task is meant to be called on ZetaChain directly to swap an asset already on ZetaChain for a different asset optionally withdrawing it.

import { task } from "hardhat/config";
import { HardhatRuntimeEnvironment } from "hardhat/types";
import { parseEther } from "@ethersproject/units";
import { ethers } from "ethers";

const main = async (args: any, hre: HardhatRuntimeEnvironment) => {
  const [signer] = await hre.ethers.getSigners();

  if (!/zeta_(testnet|mainnet)/.test(hre.network.name)) {
    throw new Error('🚨 Please use either "zeta_testnet" or "zeta_mainnet".');
  }

  const factory = await hre.ethers.getContractFactory("SwapToAnyToken");
  const contract = factory.attach(args.contract);

  const amount = parseEther(args.amount);
  const inputToken = args.inputToken;
  const targetToken = args.targetToken;
  const recipient = ethers.utils.arrayify(args.recipient);
  const withdraw = JSON.parse(args.withdraw);

  const erc20Factory = await hre.ethers.getContractFactory("ERC20");
  const inputTokenContract = erc20Factory.attach(args.inputToken);

  const approval = await inputTokenContract.approve(args.contract, amount);
  await approval.wait();

  const tx = await contract.swap(
    inputToken,
    amount,
    targetToken,
    recipient,
    withdraw
  );

  await tx.wait();
  console.log(`Transaction hash: ${tx.hash}`);
};

task("swap", "Interact with the Swap contract from ZetaChain", main)
  .addFlag("json", "Output JSON")
  .addParam("contract", "Contract address")
  .addParam("amount", "Token amount to send")
  .addParam("inputToken", "Input token address")
  .addParam("targetToken", "Target token address")
  .addParam("recipient", "Recipient address")
  .addParam("withdraw", "Withdraw flag (true/false)");
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: https://athens.explorer.zetachain.com/address/0x48D512595699A1c1c40C7B5Fc378512Ab0dCFAd7
🌍 Blockcsout: https://zetachain-athens-3.blockscout.com/address/0x48D512595699A1c1c40C7B5Fc378512Ab0dCFAd7

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)