Build
Tutorials
Swap Any Token on Localnet

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 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 cannot deploy this tutorial on testnet yet.

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 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;
        }
 
        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 --name SwapToAnyToken

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

🔑 Using account: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266

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

Ensure that you provide the systemContractAddress and gatewayAddress when deploying the contract. For localnet, these addresses remain the same.

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

npx hardhat evm-deposit-and-call --network localhost --receiver 0x67d269191c92Caf3cD7723F116c85e6E9bf55933 --amount 1 --types '["address", "bytes", "bool"]' 0x9fd96203f7b22bCF72d9DCb40ff98302376cE09c 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 true

This command sends tokens to the SwapToAnyToken contract on ZetaChain and instructs it to withdraw the tokens to a connected chain. You’ll need to provide the receiver address (the contract), the amount of tokens to swap, the target token address on ZetaChain, the recipient address on the destination chain, and the withdraw flag set to true.

If you want to swap tokens and keep them on ZetaChain rather than withdrawing them, set the withdraw flag to false. This can be done using the new swap function, which allows direct interaction with the contract on ZetaChain. Use the following command:

npx hardhat swap --network localhost --contract 0x67d269191c92Caf3cD7723F116c85e6E9bf55933 --inputToken 0x91d18e54DAf4F677cB28167158d6dd21F6aB3921 --amount 1 --targetToken 0x9fd96203f7b22bCF72d9DCb40ff98302376cE09c --recipient 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 --withdraw false

This command will perform the token swap directly on ZetaChain without withdrawing the tokens. You need to provide the contract address, the input token address, the amount to swap, the target token address, and the recipient address on ZetaChain.

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