Build
Tutorials
Swap Any Token

In the previous Swap tutorial, you created a universal swap contract that allows users to exchange tokens from one connected blockchain for a token on another blockchain. In that implementation, the swapped token was always withdrawn to the destination chain. This tutorial expands on that by enhancing the contract to support swapping tokens to any token (such as ZRC-20, ERC-20, or ZETA) and offering users the flexibility to either withdraw the token to the destination chain or retain it on ZetaChain.

The ability to keep swapped tokens on ZetaChain can be particularly useful if you intend to utilize ZRC-20 tokens in non-universal contracts that aren't yet equipped to accept tokens from connected chains. It is also useful if the destination token is ZETA, which you may want to keep on ZetaChain for further use.

In this enhanced version, you will modify the original swap contract to support this additional functionality. You will also deploy the modified contract to localnet and interact with it by swapping tokens from a connected EVM chain.

This tutorial relies on the Gateway, which is currently available only on localnet and testnet.

To get started, 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 builds on the previous swap contract by allowing users to swap tokens to any target token and giving them the option to either withdraw the swapped tokens to the destination chain or keep them on ZetaChain. This added flexibility makes the contract more versatile for a variety of use cases.

// 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 onCall(
        MessageContext 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;
        }
 
        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;
        uint256 swapAmount = amount;
 
        if (withdraw) {
            (gasZRC20, gasFee) = IZRC20(targetToken).withdrawGasFee();
 
            if (gasZRC20 == inputToken) {
                swapAmount = amount - gasFee;
            } else {
                inputForGas = SwapHelperLib.swapTokensForExactTokens(
                    systemContract,
                    inputToken,
                    gasFee,
                    gasZRC20,
                    amount
                );
                swapAmount = amount - inputForGas;
            }
        }
 
        uint256 outputAmount = SwapHelperLib.swapExactTokensForTokens(
            systemContract,
            inputToken,
            swapAmount,
            targetToken,
            0
        );
 
        if (withdraw) {
            if (gasZRC20 == targetToken) {
                IZRC20(gasZRC20).approve(
                    address(gateway),
                    outputAmount + gasFee
                );
            } else {
                IZRC20(gasZRC20).approve(address(gateway), gasFee);
                IZRC20(targetToken).approve(address(gateway), outputAmount);
            }
            gateway.withdraw(
                recipient,
                outputAmount,
                targetToken,
                RevertOptions({
                    revertAddress: address(0),
                    callOnRevert: false,
                    abortAddress: address(0),
                    revertMessage: "",
                    onRevertGasLimit: 0
                })
            );
        } else {
            IWETH9(targetToken).transfer(
                address(uint160(bytes20(recipient))),
                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);
    }
 
    function onRevert(RevertContext calldata revertContext) external override {}
}

The contract introduces a key enhancement: a withdraw flag. This flag determines whether the swapped tokens should be withdrawn to a connected chain or remain on ZetaChain. Additionally, the contract supports both cross-chain calls and direct interactions on ZetaChain, making it useful for scenarios where tokens are already on ZetaChain and you don’t need to involve a connected chain.

Differences Between Swap and SwapToAnyToken Contracts

In this new version, the core structure remains similar, but several key changes have been made to extend its functionality.

First, the Params struct has been updated to include a withdraw flag. This allows users to specify whether they want the swapped tokens withdrawn to a connected chain or kept on ZetaChain. The onCrossChainCall function now decodes this additional flag from the incoming message. For EVM chains and Solana, the contract decodes the withdraw flag alongside other parameters. For Bitcoin, due to the smaller message size allowed by its OP_RETURN, the contract checks if the message length is sufficient before extracting the withdraw flag.

The swapAndWithdraw function has also been modified to conditionally handle gas fees based on whether the tokens will be withdrawn. If the withdraw flag is set to true, the contract proceeds with the usual gas fee calculation and deduction. If the flag is false, it skips the gas fee handling and simply swaps the full amount of tokens.

Once the tokens are swapped, the contract either withdraws them to the destination chain or transfers them directly on ZetaChain. When withdraw is true, it follows the same withdrawal process as the original contract, using the gateway to send tokens to the connected chain. However, if withdraw is false, it transfers the tokens directly to the recipient on ZetaChain without involving the gateway.

Additionally, a new public swap function has been introduced, which allows users to interact with the contract directly on ZetaChain. This function is particularly useful if you already have tokens on ZetaChain and want to swap them without making a cross-chain call. It takes in parameters similar to those in onCrossChainCall, transfers the input tokens from the sender to the contract, and then calls swapAndWithdraw to perform the swap and handle withdrawal or direct transfer based on the withdraw flag.

Finally, the contract now imports the IWETH9 interface to handle direct token transfers when withdraw is false. This interface facilitates the transfer of wrapped tokens on ZetaChain.

To simulate ZetaChain’s behavior locally, start the local development environment by running:

npx hardhat localnet

Once your environment is set up, compile the contract and deploy it to localnet using the following command:

yarn deploy-any:localnet

After deployment, you should see an output similar to this:

🔑 Using account: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266

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

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

npx hardhat swap-from-evm --network localhost --receiver 0x84eA74d481Ee0A5332c457a4d796187F6Ba67fEB --amount 1 --target 0x9fd96203f7b22bCF72d9DCb40ff98302376cE09c --recipient 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 --withdraw true
  • EVM gateway is called with 1 native gas token (ETH) and a message that contains target ZRC-20 token address, receiver address and a boolean that indicates to withdraw a token to the destination chain or not.
  • onCrossChainCall is called
  • If withdraw is:
    • true, withdraw the ZRC-20 to the destination chain as a native token
    • false, send target ZRC-20 to the recipient

In the command above the withdraw is true, so the target ZRC-20 token will be transferred to the destination chain.

If you want to swap tokens and keep them on ZetaChain rather than withdrawing them, set the withdraw flag.

npx hardhat swap-from-evm --network localhost --receiver 0x84eA74d481Ee0A5332c457a4d796187F6Ba67fEB --amount 1 --target 0x9fd96203f7b22bCF72d9DCb40ff98302376cE09c --recipient 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 --withdraw false

In the command above the withdraw is false, so the target ZRC-20 token will be transferred to the recipient on ZetaChain.

To swap a ZRC-20 token for another ZRC-20 on ZetaChain and withdraw to a connected chain, run:

npx hardhat swap-from-zetachain --network localhost --contract 0x84eA74d481Ee0A5332c457a4d796187F6Ba67fEB --amount 1 --target 0x2ca7d64A7EFE2D62A725E2B35Cf7230D6677FfEe --recipient 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 --zrc20 0x9fd96203f7b22bCF72d9DCb40ff98302376cE09c --withdraw true

To swap a ZRC-20 token for another ZRC-20 on ZetaChain and transfer it to a recipient on ZetaChain, run:

npx hardhat swap-from-zetachain --network localhost --contract 0x84eA74d481Ee0A5332c457a4d796187F6Ba67fEB --amount 1 --target 0x2ca7d64A7EFE2D62A725E2B35Cf7230D6677FfEe --recipient 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 --zrc20 0x9fd96203f7b22bCF72d9DCb40ff98302376cE09c --withdraw false

In this tutorial, you extended the functionality of the original swap contract by adding the ability to swap tokens to any token and decide whether to withdraw them to a connected chain or keep them on ZetaChain. You also learned how to deploy the contract and interact with it both via cross-chain calls and directly on ZetaChain, providing greater flexibility for a variety of use cases.

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)]