Build
Tutorials
Swap

In this tutorial, you will create a cross-chain swap contract. This contract will enable users to exchange a native gas token or a supported ERC-20 token from one connected blockchain for a token on another blockchain. For example, a user will be able to swap USDC from Ethereum to BTC on Bitcoin in a single transaction.

You will learn how to:

  • Define a universal app contract that performs token swaps across chains.
  • Deploy the contract to localnet.
  • Interact with the contract by swapping tokens from a connected EVM blockchain in localnet.

The swap contract will be implemented as a universal app and deployed on ZetaChain.

Universal apps can accept token transfers and contract calls from connected chains. Tokens transferred from connected chains to a universal app contract are represented as ZRC-20. For example, ETH transferred from Ethereum is represented as ZRC-20 ETH. ZRC-20 tokens have the unique property of being able to be withdrawn back to their original chain as native assets.

The swap contract will:

  • Accept a contract call from a connected chain containing native gas or supported ERC-20 tokens and a message.
  • Decode the message, which should include:
    • Target token address (represented as ZRC-20)
    • Recipient address on the destination chain
  • Query withdraw gas fee of the target token.
  • Swap a fraction of the input token for a ZRC-20 gas token to cover the withdrawal fee using the Uniswap v2 liquidity pools.
  • Swap the remaining input token amount for the target token ZRC-20.
  • Withdraw ZRC-20 tokens to the destination chain.
This tutorial relies on the Gateway, which is currently available only on localnet and testnet.

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 Swap contract is a universal application that facilitates cross-chain token swaps on ZetaChain. It inherits from the UniversalContract interface and handles incoming cross-chain calls, processes token swaps using ZetaChain's liquidity pools, and sends the swapped tokens to the recipient on the target chain.

// 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 {GatewayZEVM} from "@zetachain/protocol-contracts/contracts/zevm/GatewayZEVM.sol";
 
contract Swap is UniversalContract {
    address public immutable uniswapRouter;
    GatewayZEVM public gateway;
    uint256 constant BITCOIN = 18332;
 
    error InvalidAddress();
    error Unauthorized();
 
    modifier onlyGateway() {
        if (msg.sender != address(gateway)) revert Unauthorized();
        _;
    }
 
    constructor(address payable gatewayAddress, address uniswapRouterAddress) {
        if (gatewayAddress == address(0) || uniswapRouterAddress == address(0))
            revert InvalidAddress();
        uniswapRouter = uniswapRouterAddress;
        gateway = GatewayZEVM(gatewayAddress);
    }
 
    struct Params {
        address target;
        bytes to;
    }
 
    function onCall(
        MessageContext calldata context,
        address zrc20,
        uint256 amount,
        bytes calldata message
    ) external onlyGateway {
        Params memory params = Params({target: address(0), to: bytes("")});
        if (context.chainID == BITCOIN) {
            params.target = BytesHelperLib.bytesToAddress(message, 0);
            params.to = abi.encodePacked(
                BytesHelperLib.bytesToAddress(message, 20)
            );
        } else {
            (address targetToken, bytes memory recipient) = abi.decode(
                message,
                (address, bytes)
            );
            params.target = targetToken;
            params.to = recipient;
        }
 
        swapAndWithdraw(zrc20, amount, params.target, params.to);
    }
 
    function swapAndWithdraw(
        address inputToken,
        uint256 amount,
        address targetToken,
        bytes memory recipient
    ) internal {
        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 outputAmount = SwapHelperLib.swapExactTokensForTokens(
            uniswapRouter,
            inputToken,
            swapAmount,
            targetToken,
            0
        );
 
        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
            })
        );
    }
 
    function onRevert(
        RevertContext calldata revertContext
    ) external onlyGateway {}
}

Decoding the Message

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

  • address target: The ZRC-20 address of the target token on ZetaChain.
  • bytes to: The recipient's address on the destination chain, stored as bytes because the recipient could be on an EVM chain (like Ethereum or BNB) or on a non-EVM chain like Bitcoin.

When the onCall function is invoked, it receives a message parameter that needs to be decoded to extract the swap details. The encoding of this message varies depending on the source chain due to different limitations and requirements.

  • For Bitcoin: Since Bitcoin has an upper limit of 80 bytes for OP_RETURN messages, the contract uses a more efficient encoding. It extracts the params.target by reading the first 20 bytes of the message and converting it to an address using the bytesToAddress helper method. The recipient's address is then obtained by reading the next 20 bytes and packing it into bytes using abi.encodePacked.

  • For EVM Chains And Solana: EVM chains don't have strict message size limits, so the contract uses abi.decode to extract the params.target and params.to directly from the message.

The context.chainID is utilized to determine the source chain and apply the appropriate decoding logic.

After decoding the message, the contract proceeds to handle the token swap and withdrawal process by calling the swapAndWithdraw function with the appropriate parameters.

Swapping and Withdrawing Tokens

The swapAndWithdraw function encapsulates the logic for swapping tokens and withdrawing them to the connected chain. By separating this logic into its own function, the code becomes cleaner and easier to maintain.

Swapping for Gas Token

The contract first addresses the gas fee required for the withdrawal on the destination chain. It uses the withdrawGasFee method of the target token's ZRC-20 contract to obtain the gas fee amount (gasFee) and the gas fee token address (gasZRC20).

If the incoming token (inputToken) is the same as the gas fee 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 fee using the swapTokensForExactTokens helper method. This ensures that the contract has enough gas tokens to cover the withdrawal fee on the destination chain.

Swapping for Target Token

Next, the contract swaps the remaining tokens (swapAmount) for the target token specified in targetToken. It uses the swapExactTokensForTokens helper method to perform this swap through ZetaChain's internal liquidity pools. This method returns the amount of the target token received (outputAmount).

Withdrawing Target Token to Connected Chain

At this stage, the contract holds the required gas fee in gasZRC20 tokens and the swapped target tokens in targetToken. It needs to approve the GatewayZEVM contract to spend these tokens before initiating the withdrawal. If the gas fee token is the same as the target token, it approves the total amount (gas fee plus output amount) for the gateway to spend. If they are different, it approves each token separatelyโ€”the gas fee token (gasZRC20) and the target token (targetToken).

Finally, the contract calls the gateway.withdraw method to send the tokens to the recipient on the connected chain. The withdraw method handles the cross-chain transfer, ensuring that the recipient receives the swapped tokens on their native chain, whether it's an EVM chain or Bitcoin.

Note that you don't have to specify which chain to withdraw to because each ZRC-20 contract knows which connected chain it is associated with. For example, ZRC-20 Ethereum USDC can only be withdrawn to Ethereum.

npx hardhat compile --force
npx hardhat deploy --gateway 0x6c533f7fe93fae114d0954697069df33c9b74fd7 --network zeta_testnet
๐Ÿ”‘ Using account: 0x4955a3F38ff86ae92A914445099caa8eA2B9bA32

๐Ÿš€ Successfully deployed contract on zeta_testnet.
๐Ÿ“œ Contract address: 0x162CefCe314726698ac1Ee5895a6c392ba8e20d3
npx hardhat evm-deposit-and-call
  --receiver 0x162CefCe314726698ac1Ee5895a6c392ba8e20d3 \
  --amount 0.001 \
  --network base_sepolia \
  --gas-price 20000 \
  --gateway-evm 0x0c487a766110c85d301d96e33579c5b317fa4995 \
  --types '["address", "bytes"]' 0x777915D031d1e8144c90D025C594b3b8Bf07a08d 0x4955a3F38ff86ae92A914445099caa8eA2B9bA32

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:localnet

You should see output similar to:

๐Ÿ”‘ Using account: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266

๐Ÿš€ Successfully deployed contract on localhost.
๐Ÿ“œ Contract address: 0x84eA74d481Ee0A5332c457a4d796187F6Ba67fEB

To swap gas tokens for ERC-20 tokens, run the following command:

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

This script deposits tokens into the gateway on a connected EVM chain and sends a message to the Swap contract on ZetaChain to execute the swap logic.

In this command, the --receiver parameter is the address of the Swap contract on ZetaChain that will handle the swap. The --amount 1 option indicates that you want to swap 1 ETH. --target is the ZRC-20 address of the destination token (in this example, it's ZRC-20 USDC).

When you execute this command, the script calls the gateway.depositAndCall method on the connected EVM chain, depositing 1 ETH and sending a message to the Swap contract on ZetaChain.

ZetaChain then picks up the event and executes the onCall function of the Swap contract with the provided message.

The Swap contract decodes the message, identifies the target ERC-20 token and recipient, and initiates the swap logic.

Finally, the EVM chain receives the withdrawal request, and the swapped ERC-20 tokens are transferred to the recipient's address:

Swapping ERC-20 Tokens for Gas Tokens

To swap ERC-20 tokens for gas tokens, adjust the command by specifying the ERC-20 token you're swapping from using the --erc20 parameter:

npx hardhat swap-from-evm --network localhost --receiver 0x84eA74d481Ee0A5332c457a4d796187F6Ba67fEB --amount 1 --target 0x2ca7d64A7EFE2D62A725E2B35Cf7230D6677FfEe --recipient 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 --erc20 0x0B306BF915C4d645ff596e518fAf3F9669b97016

Here, the --erc20 option specifies the ERC-20 token address you're swapping from on the source chain. The other parameters remain the same as in the previous command.

When you run the command, the script calls the gateway.depositAndCall method with the specified ERC-20 token and amount, sending a message to the Swap contract on ZetaChain.

ZetaChain picks up the event and executes the onCall function of the Swap contract:

The Swap contract decodes the message, identifies the target gas token and recipient, and initiates the swap logic.

The EVM chain then receives the withdrawal request, and the swapped gas tokens are transferred to the recipient's address.

In this tutorial, you learned how to define a universal app contract that performs cross-chain token swaps. You deployed the Swap contract to a local development network and interacted with the contract by swapping tokens from a connected EVM chain. You also understood the mechanics of handling gas fees and token approvals in cross-chain swaps.

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

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