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 {
    address public immutable uniswapRouter;
    GatewayZEVM public gateway;
    uint256 constant BITCOIN = 18332;
    uint256 public immutable gasLimit;
 
    error InvalidAddress();
    error Unauthorized();
    error ApprovalFailed();
    error TransferFailed();
 
    modifier onlyGateway() {
        if (msg.sender != address(gateway)) revert Unauthorized();
        _;
    }
 
    constructor(
        address payable gatewayAddress,
        address uniswapRouterAddress,
        uint256 gasLimitAmount
    ) {
        if (gatewayAddress == address(0) || uniswapRouterAddress == address(0))
            revert InvalidAddress();
        uniswapRouter = uniswapRouterAddress;
        gateway = GatewayZEVM(gatewayAddress);
        gasLimit = gasLimitAmount;
    }
 
    struct Params {
        address target;
        bytes to;
        bool withdraw;
    }
 
    function onCall(
        MessageContext calldata context,
        address zrc20,
        uint256 amount,
        bytes calldata message
    ) external onlyGateway {
        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 out, address gasZRC20, uint256 gasFee) = handleGasAndSwap(
            zrc20,
            amount,
            params.target
        );
        withdraw(params, context.sender, gasFee, gasZRC20, out, zrc20);
    }
 
    function swap(
        address inputToken,
        uint256 amount,
        address targetToken,
        bytes memory recipient,
        bool withdrawFlag
    ) public {
        bool success = IZRC20(inputToken).transferFrom(
            msg.sender,
            address(this),
            amount
        );
        if (!success) {
            revert TransferFailed();
        }
 
        (uint256 out, address gasZRC20, uint256 gasFee) = handleGasAndSwap(
            inputToken,
            amount,
            targetToken
        );
 
        withdraw(
            Params({
                target: targetToken,
                to: recipient,
                withdraw: withdrawFlag
            }),
            msg.sender,
            gasFee,
            gasZRC20,
            out,
            inputToken
        );
    }
 
    function handleGasAndSwap(
        address inputToken,
        uint256 amount,
        address targetToken
    ) internal returns (uint256, address, uint256) {
        uint256 inputForGas;
        address gasZRC20;
        uint256 gasFee;
        uint256 swapAmount;
 
        (gasZRC20, gasFee) = IZRC20(targetToken).withdrawGasFee();
 
        if (gasZRC20 == inputToken) {
            swapAmount = amount - gasFee;
        } else {
            inputForGas = SwapHelperLib.swapTokensForExactTokens(
                uniswapRouter,
                inputToken,
                gasFee,
                gasZRC20,
                amount
            );
            swapAmount = amount - inputForGas;
        }
 
        uint256 out = SwapHelperLib.swapExactTokensForTokens(
            uniswapRouter,
            inputToken,
            swapAmount,
            targetToken,
            0
        );
        return (out, gasZRC20, gasFee);
    }
 
    function withdraw(
        Params memory params,
        address sender,
        uint256 gasFee,
        address gasZRC20,
        uint256 out,
        address inputToken
    ) public {
        if (params.withdraw) {
            if (gasZRC20 == params.target) {
                if (!IZRC20(gasZRC20).approve(address(gateway), out + gasFee)) {
                    revert ApprovalFailed();
                }
            } else {
                if (!IZRC20(gasZRC20).approve(address(gateway), gasFee)) {
                    revert ApprovalFailed();
                }
                if (!IZRC20(params.target).approve(address(gateway), out)) {
                    revert ApprovalFailed();
                }
            }
            gateway.withdraw(
                abi.encodePacked(params.to),
                out,
                params.target,
                RevertOptions({
                    revertAddress: address(this),
                    callOnRevert: true,
                    abortAddress: address(0),
                    revertMessage: abi.encode(sender, inputToken),
                    onRevertGasLimit: gasLimit
                })
            );
        } else {
            bool success = IWETH9(params.target).transfer(
                address(uint160(bytes20(params.to))),
                out
            );
            if (!success) {
                revert TransferFailed();
            }
        }
    }
 
    function onRevert(RevertContext calldata context) external onlyGateway {
        (address sender, address zrc20) = abi.decode(
            context.revertMessage,
            (address, address)
        );
        (uint256 out, , ) = handleGasAndSwap(
            context.asset,
            context.amount,
            zrc20
        );
 
        gateway.withdraw(
            abi.encodePacked(sender),
            out,
            zrc20,
            RevertOptions({
                revertAddress: sender,
                callOnRevert: false,
                abortAddress: address(0),
                revertMessage: "",
                onRevertGasLimit: gasLimit
            })
        );
    }
 
    fallback() external payable {}
 
    receive() external payable {}
}

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 onCall 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 onCall, 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:

npx hardhat deploy \
  --name SwapToAnyToken \
  --network localhost \
  --gateway 0x5FC8d32690cc91D4c39d9d3abcBD16989F875707 \
  --uniswap-router 0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0

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

🔑 Using account: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266

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

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 0xc351628EB244ec633d5f21fBD6621e1a683B1181 \
  --amount 1 \
  --target 0x05BA149A7bd6dC1F937fA9046A9e05C05f3b18b0 \
  --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.
  • onCall 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 0xc351628EB244ec633d5f21fBD6621e1a683B1181 \
  --amount 1 \
  --target 0x05BA149A7bd6dC1F937fA9046A9e05C05f3b18b0 \
  --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 0xc351628EB244ec633d5f21fBD6621e1a683B1181 \
  --amount 1 \
  --target 0x65a45c57636f9BcCeD4fe193A602008578BcA90b \
  --recipient 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 \
  --zrc20 0x05BA149A7bd6dC1F937fA9046A9e05C05f3b18b0 \
  --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 0xc351628EB244ec633d5f21fBD6621e1a683B1181 \
  --amount 1 \
  --target 0x65a45c57636f9BcCeD4fe193A602008578BcA90b \
  --recipient 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 \
  --zrc20 0x05BA149A7bd6dC1F937fA9046A9e05C05f3b18b0 \
  --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)