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 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 can't deploy this tutorial on testnet yet. For a tutorial that works with the current testnet, check out the Swap on Testnet tutorial.
Setting Up Your Environment
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
Understanding the Swap Contract
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 {
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;
}
function onCrossChainCall(
zContext calldata context,
address zrc20,
uint256 amount,
bytes calldata message
) external override {
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(
systemContract,
inputToken,
gasFee,
gasZRC20,
amount
);
swapAmount = amount - inputForGas;
}
uint256 outputAmount = SwapHelperLib.swapExactTokensForTokens(
systemContract,
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 override {}
}
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 asbytes
because the recipient could be on an EVM chain (like Ethereum or BNB) or on a non-EVM chain like Bitcoin.
When the onCrossChainCall
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 themessage
and converting it to anaddress
using thebytesToAddress
helper method. The recipient's address is then obtained by reading the next 20 bytes and packing it intobytes
usingabi.encodePacked
. -
For EVM Chains And Solana: EVM chains don't have strict message size limits, so the contract uses
abi.decode
to extract theparams.target
andparams.to
directly from themessage
.
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.
Deploying the Contract
Compile the contract and deploy it to localnet by running:
yarn deploy
You should see output similar to:
🔑 Using account: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
🚀 Successfully deployed contract on localhost.
📜 Contract address: 0x67d269191c92Caf3cD7723F116c85e6E9bf55933
Starting Localnet
Start the local development environment to simulate ZetaChain's behavior by running:
npx hardhat localnet
Swapping Gas Tokens for ERC-20 Tokens
To swap gas tokens (such as ETH) for ERC-20 tokens, run the following command:
npx hardhat evm-deposit-and-call --network localhost --receiver 0x67d269191c92Caf3cD7723F116c85e6E9bf55933 --amount 1 --types '["address", "bytes"]' 0x9fd96203f7b22bCF72d9DCb40ff98302376cE09c 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
This script deposits tokens into the ZetaChain gateway 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 (0x67d269191c92Caf3cD7723F116c85e6E9bf55933
) that will handle the
swap. The --amount 1
option indicates that you want to swap 1 ETH. The
--types '["address", "bytes"]'
parameter defines the ABI types of the message
parameters being sent to the onCrossChainCall
function in the Swap contract.
The two addresses that follow are the target ERC-20 token address on the
destination chain (0x9fd96203f7b22bCF72d9DCb40ff98302376cE09c
) and the
recipient address (0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
) who will
receive the swapped tokens.
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. The EVM gateway processes the deposit and emits a
Deposited
event:
[EVM]: Gateway: 'Deposited' event emitted
ZetaChain then picks up the event and executes the onCrossChainCall
function
of the Swap contract with the provided message. The execution log might look
like this:
[ZetaChain]: Universal contract 0x67d269191c92Caf3cD7723F116c85e6E9bf55933 executing onCrossChainCall (context: {"origin":"0xA51c1fc2f0D1a1b8494Ed1FE312d7C3a78Ed91C0","sender":"0x735b14BB79463307AAcBED86DAf3322B1e6226aB","chainID":1}), zrc20: 0x91d18e54DAf4F677cB28167158d6dd21F6aB3921, amount: 1000000000000000000, message: 0x0000000000000000000000009fd96203f7b22bcf72d9dcb40ff98302376ce09c00000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014f39fd6e51aad88f6f4ce6ab8827279cfffb92266000000000000000000000000)
In this context, origin
refers to the original sender's address, sender
is
the address that initiated the cross-chain call, and chainID
identifies the
source chain. The zrc20
field shows the ZRC-20 representation of the deposited
token on ZetaChain, and amount
is the number of tokens received. The message
contains the encoded parameters sent to onCrossChainCall
.
The Swap contract decodes the message, identifies the target ERC-20 token and
recipient, and initiates the swap logic. After processing, the ZetaChain gateway
emits a Withdrawn
event:
[ZetaChain]: Gateway: 'Withdrawn' event emitted
Finally, the EVM chain receives the withdrawal request, and the swapped ERC-20 tokens are transferred to the recipient's address:
[EVM]: Transferred 1.013466046281196713 ERC-20 tokens from Custody to 0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266
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 evm-deposit-and-call --network localhost --receiver 0x67d269191c92Caf3cD7723F116c85e6E9bf55933 --amount 1 --erc20 0x0DCd1Bf9A1b36cE34237eEaFef220932846BCD82 --types '["address", "bytes"]' 0x91d18e54DAf4F677cB28167158d6dd21F6aB3921 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
Here, the --erc20
option specifies the ERC-20 token address you're swapping
from on the source chain (0x0DCd1Bf9A1b36cE34237eEaFef220932846BCD82
). 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. The EVM gateway processes the deposit of the ERC-20
tokens and emits a Deposited
event:
[EVM]: Gateway: 'Deposited' event emitted
ZetaChain picks up the event and executes the onCrossChainCall
function of the
Swap contract:
[ZetaChain]: Universal contract 0x67d269191c92Caf3cD7723F116c85e6E9bf55933 executing onCrossChainCall (context: {"origin":"0xA51c1fc2f0D1a1b8494Ed1FE312d7C3a78Ed91C0","sender":"0x735b14BB79463307AAcBED86DAf3322B1e6226aB","chainID":1}), zrc20: 0x9fd96203f7b22bCF72d9DCb40ff98302376cE09c, amount: 1000000000000000000, message: 0x00000000000000000000000091d18e54daf4f677cb28167158d6dd21f6ab392100000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014f39fd6e51aad88f6f4ce6ab8827279cfffb92266000000000000000000000000)
The Swap contract decodes the message, identifies the target gas token and
recipient, and initiates the swap logic. After processing, the ZetaChain gateway
emits a Withdrawn
event:
[ZetaChain]: Gateway: 'Withdrawn' event emitted
The EVM chain then receives the withdrawal request, and the swapped gas tokens are transferred to the recipient's address:
[EVM]: Transferred 0.974604535974342599 native gas tokens from TSS to 0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266
Conclusion
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.
Source Code
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)