Build
Tutorials
Swap

Swap

In this tutorial you will write a cross-chain swap contract that allows users to swap a native gas or ERC-20 token from one of the connected chains for a token on another chain.

The swap process involves depositing a token from a connected chain to ZetaChain, triggering a swap omnichain contract to swap between ZRC-20 representations of the tokens and then withdrawing the swapped token to the recipient address on the destination chain.

Clone the Hardhat contract template:

git clone https://github.com/zeta-chain/template

Install dependencies:

cd template
yarn

Run the following command to create a new omnichain contract called Swap.

npx hardhat omnichain Swap targetToken:address recipient
contracts/Swap.sol
// SPDX-License-Identifier: MIT
pragma solidity 0.8.7;
 
import "@zetachain/protocol-contracts/contracts/zevm/SystemContract.sol";
import "@zetachain/protocol-contracts/contracts/zevm/interfaces/zContract.sol";
import "@zetachain/toolkit/contracts/SwapHelperLib.sol";
import "@zetachain/toolkit/contracts/BytesHelperLib.sol";
import "@zetachain/toolkit/contracts/OnlySystem.sol";
 
contract Swap is zContract, OnlySystem {
    SystemContract public systemContract;
    uint256 constant BITCOIN = 18332;
 
    constructor(address systemContractAddress) {
        systemContract = SystemContract(systemContractAddress);
    }
 
    function onCrossChainCall(
        zContext calldata context,
        address zrc20,
        uint256 amount,
        bytes calldata message
    ) external virtual override onlySystem(systemContract) {
        address targetTokenAddress;
        bytes memory recipientAddress;
 
        if (context.chainID == BITCOIN) {
            targetTokenAddress = BytesHelperLib.bytesToAddress(message, 0);
            recipientAddress = abi.encodePacked(
                BytesHelperLib.bytesToAddress(message, 20)
            );
        } else {
            (address targetToken, bytes memory recipient) = abi.decode(
                message,
                (address, bytes)
            );
            targetTokenAddress = targetToken;
            recipientAddress = recipient;
        }
 
        (address gasZRC20, uint256 gasFee) = IZRC20(targetTokenAddress)
            .withdrawGasFee();
 
        uint256 inputForGas = SwapHelperLib.swapTokensForExactTokens(
            systemContract,
            zrc20,
            gasFee,
            gasZRC20,
            amount
        );
 
        uint256 outputAmount = SwapHelperLib.swapExactTokensForTokens(
            systemContract,
            zrc20,
            amount - inputForGas,
            targetTokenAddress,
            0
        );
 
        IZRC20(gasZRC20).approve(targetTokenAddress, gasFee);
        IZRC20(targetTokenAddress).withdraw(recipientAddress, outputAmount);
    }
}

The contract expects to receive two values in the message:

  • address targetTokenAddress: the address of the ZRC-20 version of the destination token.
  • bytes memory recipientAddress: the recipient address on the destination chain. We're using bytes, because the recipient address can be either on an EVM chain or Bitcoin.

When the contract is called from an EVM chain, the message is encoded as a bytes array using the ABI encoding.

When the contract is called from Bitcoin it’s up to us to encode and then decode the message.

Use context.chainID to determine the connected chain from which the contract is called.

If it’s Bitcoin, the first 20 bytes of the message are the targetTokenAddress encoded as an address. Use bytesToAddress helper method to get the target token address. To get the recipient address, use the same helper method with an offset of 20 bytes and then use abi.encodePacked to convert the address to bytes.

If it’s an EVM chain, use abi.decode to decode the message into the targetToken and recipient variables.

Next, get the gas fee and the gas coin address from the target token. The gas coin is the token that will be used to pay for the gas on the destination chain.

Use the SwapHelperLib.swapTokensForExactTokens helper method to swap the incoming token for the gas coin using the internal liquidity pools. The method returns the amount of the incoming token that was used to pay for the gas.

Next, swap the incoming amount minus the amount spent swapping for a gas fee for the target token on the destination chain using the SwapHelperLib._doSwap helper method.

Finally, withdraw the tokens to the recipient address on the destination chain.

In the interact task generated for us by the contract template the recipient is encoded as string. Our contract, however, expects the recipient to be encoded as bytes to ensure that both EVM and Bitcoin addresses are supported.

To support both EVM and Bitcoin addresses, we need to check if the recipient is a valid Bitcoin address. If it is, we need to encode it as bytes using utils.solidityPack.

If it’s not a valid bech32 address, then we assume it’s an EVM address and use args.recipient as the value for the recipient.

Finally, update the prepareData function call to use the bytes type for the recipient.

tasks/interact.ts
import bech32 from "bech32";
 
const main = async (args: any, hre: HardhatRuntimeEnvironment) => {
  const [signer] = await hre.ethers.getSigners();
 
  let recipient;
  try {
    if (bech32.decode(args.recipient)) {
      recipient = utils.solidityPack(["bytes"], [utils.toUtf8Bytes(args.recipient)]);
    }
  } catch (e) {
    recipient = args.recipient;
  }
 
  const data = prepareData(args.contract, ["address", "bytes"], [args.targetToken, recipient]);
  //...
};

Before proceeding with the next steps, make sure you have created an account and requested ZETA tokens from the faucet.

npx hardhat compile --force
npx hardhat deploy --network zeta_testnet
🔑 Using account: 0x2cD3D070aE1BD365909dD859d29F387AA96911e1

🚀 Successfully deployed contract on ZetaChain.
📜 Contract address: 0xf6CDd83AB44E4d947FE52c2637ee4A04F330328E
🌍 Explorer: https://athens3.explorer.zetachain.com/address/0xf6CDd83AB44E4d947FE52c2637ee4A04F330328E

Use the interact task to perform a cross-chain swap. In this example, we're swapping native sETH from Sepolia for tMATIC on Polygon Mumbai. The contract will deposit sETH to ZetaChain as ZRC-20, swap it for ZRC-20 tMATIC and then withdraw native tMATIC Polygon Mumbai. To get the value of the --target-token find the ZRC-20 contract address of the destination token in the ZRC-20 section of the docs.

npx hardhat interact --contract 0xf6CDd83AB44E4d947FE52c2637ee4A04F330328E --amount 0.01 --network sepolia_testnet --target-token 0x48f80608B672DC30DC7e3dbBd0343c5F02C738Eb --recipient 0x2cD3D070aE1BD365909dD859d29F387AA96911e1
🔑 Using account: 0x2cD3D070aE1BD365909dD859d29F387AA96911e1

🚀 Successfully broadcasted a token transfer transaction on sepolia_testnet network.
📝 Transaction hash: 0x6b4156c195d955d1325a5e6275214db63ff2e3642838607333e74abd74b8fc13

Track your cross-chain transaction:

npx hardhat cctx 0x6b4156c195d955d1325a5e6275214db63ff2e3642838607333e74abd74b8fc13
✓ CCTXs on ZetaChain found.

✓ 0xb263483d61d0b1c89685364324c32438a3546b3f732a3d37840e6042e4837357: 5 → 7001: OutboundMined (Remote omnichain contract call completed)
✓ 0xcad3a0b8949b1bd39c51e80ff6be9a5c6d337df8a04b39a48667d2c688172e35: 7001 → 80001: PendingOutbound → OutboundMined

Now let's swap USDC from Sepolia to MATIC on Polygon Mumbai. To send USDC specify the ERC-20 token contract address (on Sepolia) in the --token parameter. You can find the address of the token in the ZRC-20 section of the docs.

npx hardhat interact --contract 0xf6CDd83AB44E4d947FE52c2637ee4A04F330328E --amount 10 --token 0x07865c6e87b9f70255377e024ace6630c1eaa37f --network sepolia_testnet --target-token 0x48f80608B672DC30DC7e3dbBd0343c5F02C738Eb --recipient 0x2cD3D070aE1BD365909dD859d29F387AA96911e1
🔑 Using account: 0x2cD3D070aE1BD365909dD859d29F387AA96911e1

🚀 Successfully broadcasted a token transfer transaction on sepolia_testnet
network.
📝 Transaction hash: 0xff32dd2391c4f62694cc99afd0da1c2a1352c8caf29846cc366aab54a631e8f8
npx hardhat cctx
0xff32dd2391c4f62694cc99afd0da1c2a1352c8caf29846cc366aab54a631e8f8
npx hardhat cctx 0xff32dd2391c4f62694cc99afd0da1c2a1352c8caf29846cc366aab54a631e8f8
✓ CCTXs on ZetaChain found.

✓ 0xa4cb698122916f10c932e146c45517b4f47de1e16be493ea66f28b5a34c7bfb5: 5 → 7001: OutboundMined (Remote omnichain contract call completed)
✓ 0xad18c759713ce5604683aeb389fc9a1a91f537c0abbb8d9f9fc6cfc11e55fdc7: 7001 → 80001: PendingOutbound  → OutboundMined

Use the send-btc task to send Bitcoin to the TSS address with a memo. The memo should contain the following:

  • Omnichain contract address on ZetaChain: f6CDd83AB44E4d947FE52c2637ee4A04F330328E
  • Target token address: 48f80608B672DC30DC7e3dbBd0343c5F02C738Eb
  • Recipient address: 2cD3D070aE1BD365909dD859d29F387AA96911e1
npx hardhat send-btc --amount 0.001 --memo f6CDd83AB44E4d947FE52c2637ee4A04F330328E48f80608B672DC30DC7e3dbBd0343c5F02C738Eb2cD3D070aE1BD365909dD859d29F387AA96911e1 --recipient tb1qy9pqmk2pd9sv63g27jt8r657wy0d9ueeh0nqur
npx hardhat cctx 3c2eeee38fafbfbcdceca0d595c1433c48c738aaa6e1df407a681aeeeb1da3d6
✓ CCTXs on ZetaChain found.

✓ 0xa7d4a46545806a5aff4d4fc20cb37295f426b70f0f6b2a123f67cbdb3014c995: 18332 → 7001: OutboundMined (Remote omnichain contract call completed)
✓ 0x963cf8890b3da9e84379eca06a2e4835aba3a027bca6560e76d19945b75b2c39: 7001 → 80001: PendingOutbound  → OutboundMined

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

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

Continue Learning

Continue with the next part or try a related tutorial