Build
Tutorials
Swap Any Token

In the previous Swap tutorial, you learned how to create a universal swap contract that enables 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.

You will learn how to:

  • Modify the swap contract to support swapping to any token.
  • Implement optional withdrawal of the swapped tokens to connected chains.
  • Deploy the modified contract to localnet.
  • Interact with the contract by swapping tokens from a connected EVM chain.

This tutorial depends on the gateway, which is available on localnet but not yet deployed on testnet. It will be compatible with testnet after the gateway is deployed. In other words, you can't deploy this tutorial on testnet yet.

To set up your environment, clone the example contracts repository and install the dependencies by running the following commands:

git clone https://github.com/zeta-chain/example-contracts
 
cd example-contracts/examples/swap
 
yarn

The SwapToAnyToken contract extends the functionality of the previous swap contract. It allows users to swap tokens to any target token and choose whether to withdraw the swapped tokens to the destination chain or keep them on ZetaChain.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
 
import {SystemContract, IZRC20} from "@zetachain/toolkit/contracts/SystemContract.sol";
import {SwapHelperLib} from "@zetachain/toolkit/contracts/SwapHelperLib.sol";
import {BytesHelperLib} from "@zetachain/toolkit/contracts/BytesHelperLib.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
 
import {RevertContext, RevertOptions} from "@zetachain/protocol-contracts/contracts/Revert.sol";
import "@zetachain/protocol-contracts/contracts/zevm/interfaces/UniversalContract.sol";
import "@zetachain/protocol-contracts/contracts/zevm/interfaces/IGatewayZEVM.sol";
import "@zetachain/protocol-contracts/contracts/zevm/interfaces/IWZETA.sol";
import {GatewayZEVM} from "@zetachain/protocol-contracts/contracts/zevm/GatewayZEVM.sol";
 
contract SwapToAnyToken is UniversalContract {
    SystemContract public systemContract;
    GatewayZEVM public gateway;
    uint256 constant BITCOIN = 18332;
 
    constructor(address systemContractAddress, address payable gatewayAddress) {
        systemContract = SystemContract(systemContractAddress);
        gateway = GatewayZEVM(gatewayAddress);
    }
 
    struct Params {
        address target;
        bytes to;
        bool withdraw;
    }
 
    function onCrossChainCall(
        zContext calldata context,
        address zrc20,
        uint256 amount,
        bytes calldata message
    ) external virtual override {
        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;
        }
 
        uint256 inputForGas;
        address gasZRC20;
        uint256 gasFee;
        uint256 swapAmount;
 
        if (params.withdraw) {
            (gasZRC20, gasFee) = IZRC20(params.target).withdrawGasFee();
 
            if (gasZRC20 == zrc20) {
                swapAmount = amount - gasFee;
            } else {
                inputForGas = SwapHelperLib.swapTokensForExactTokens(
                    systemContract,
                    zrc20,
                    gasFee,
                    gasZRC20,
                    amount
                );
                swapAmount = amount - inputForGas;
            }
        }
 
        uint256 outputAmount = SwapHelperLib.swapExactTokensForTokens(
            systemContract,
            zrc20,
            swapAmount,
            params.target,
            0
        );
 
        if (params.withdraw) {
            if (gasZRC20 == params.target) {
                IZRC20(gasZRC20).approve(
                    address(gateway),
                    outputAmount + gasFee
                );
            } else {
                IZRC20(gasZRC20).approve(address(gateway), gasFee);
                IZRC20(params.target).approve(address(gateway), outputAmount);
            }
            gateway.withdraw(
                params.to,
                outputAmount,
                params.target,
                RevertOptions({
                    revertAddress: address(0),
                    callOnRevert: false,
                    abortAddress: address(0),
                    revertMessage: "",
                    onRevertGasLimit: 0
                })
            );
        } else {
            IWETH9(params.target).transfer(
                address(uint160(bytes20(params.to))),
                outputAmount
            );
        }
    }
 
    function onRevert(RevertContext calldata revertContext) external override {}
}

The contract defines a Params struct to store three crucial pieces of information:

  • address target: The address of the target token on ZetaChain.
  • bytes to: The recipient's address on the destination chain or ZetaChain.
  • bool withdraw: A flag indicating whether to withdraw the swapped tokens to the destination chain (true) or keep them on ZetaChain (false).

When the onCrossChainCall function is invoked, it decodes the message parameter to extract the swap details. The decoding process varies depending on the source chain:

  • For Bitcoin: Due to Bitcoin's OP_RETURN size limitations, the contract uses helper functions like bytesToAddress and bytesToBool to parse the message manually.
  • For EVM Chains and Solana: The contract uses abi.decode to extract the targetToken, recipient, and withdrawFlag directly from the message.

Swapping for Gas Tokens

If the withdraw flag is true, the contract handles the gas fee required for the withdrawal on the destination chain:

  • It obtains the required gasFee and the gas token (gasZRC20) by calling withdrawGasFee() on the target ZRC-20 token.
  • If the incoming token (zrc20) is the same as the gas token (gasZRC20), it deducts the gas fee directly from the incoming amount.
  • Otherwise, it swaps a portion of the incoming tokens for the required gas tokens using swapTokensForExactTokens.

Swapping to Target Token

The contract swaps the remaining tokens (swapAmount) for the target token specified in params.target using swapExactTokensForTokens. This method returns the amount of the target token received (outputAmount).

Withdrawal or Transfer

After the swap, the contract either withdraws the tokens to the connected chain or transfers them on ZetaChain, depending on the withdraw flag:

  • If Withdrawing (withdraw == true):
    • The contract approves the gateway to spend the gas tokens and the target tokens.
    • It calls withdraw on the gateway to send the tokens to the recipient on the connected chain.
  • If Not Withdrawing (withdraw == false):
    • The contract transfers the tokens to the recipient's address on ZetaChain. If the target token is WZETA (Wrapped ZETA), it unwraps it to native ZETA before transferring.

Start the local development environment to simulate ZetaChain's behavior by running:

npx hardhat localnet

Compile the contract and deploy it to localnet by running:

yarn deploy --name SwapToAnyToken

You should see output similar to:

🔑 Using account: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266

🚀 Successfully deployed contract on localhost.
📜 Contract address: 0x67d269191c92Caf3cD7723F116c85e6E9bf55933

Make sure to provide the systemContractAddress and gatewayAddress when deploying the contract. In localnet, these addresses are the same.

To swap tokens from a connected EVM chain and withdraw them to the connected chain, use the following command:

npx hardhat evm-deposit-and-call --network localhost --receiver 0x67d269191c92Caf3cD7723F116c85e6E9bf55933 --amount 1 --types '["address", "bytes", "bool"]' 0x9fd96203f7b22bCF72d9DCb40ff98302376cE09c 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 true
  • --receiver is the address of the SwapToAnyToken contract on ZetaChain.
  • --amount 1 indicates that you want to swap 1 ETH (or the connected chain's native token).
  • --types '["address", "bytes", "bool"]' defines the ABI types of the message parameters being sent to the onCrossChainCall function.
  • The parameters following the types are:
    • 0x9fd96203f7b22bCF72d9DCb40ff98302376cE09c: The target token address on ZetaChain.
    • 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266: The recipient's address.
    • true: The withdraw flag set to true.

When you execute this command, the script calls the depositAndCall method on the connected EVM chain, depositing tokens and sending a message to the SwapToAnyToken contract on ZetaChain. The EVM gateway processes the deposit and emits a Deposited event.

ZetaChain picks up the event and executes the onCrossChainCall function of the SwapToAnyToken contract. The contract decodes the message, performs the swap, and withdraws the tokens to the recipient on the connected chain.

In this tutorial, you learned how to enhance the swap contract to support swapping tokens to any token, with the option to withdraw the swapped tokens to the destination chain or keep them on ZetaChain. You also learned how to interact with the contract from connected chains.

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

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