In the previous Swap tutorial, you learned how to create a universal swap contract that enables users to exchange tokens from one connected blockchain for a token on another blockchain, with the target token always withdrawn to the destination chain.
In this tutorial, you will enhance the swap contract to support swapping tokens to any token (such as ZRC-20, ERC-20, or ZETA) and provide the flexibility to either withdraw the token to the destination chain or keep it on ZetaChain.
Keeping swapped tokens on ZetaChain is useful if you want to use ZRC-20 in non-universal contracts that don't yet have the capacity to accept tokens from connected chains directly, or if the destination token is ZETA, which you want to keep on ZetaChain.
You will learn how to:
- Modify the swap contract to support swapping to any token.
- Implement optional withdrawal of the swapped tokens to connected chains.
- Deploy the modified contract to localnet.
- Interact with the contract 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 can't deploy this tutorial on testnet yet.
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 SwapToAnyToken Contract
The SwapToAnyToken
contract extends the functionality of the previous swap
contract. It allows users to swap tokens to any target token and choose whether
to withdraw the swapped tokens to the destination chain or keep them on
ZetaChain.
// 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;
}
uint256 inputForGas;
address gasZRC20;
uint256 gasFee;
uint256 swapAmount = amount;
if (params.withdraw) {
(gasZRC20, gasFee) = IZRC20(params.target).withdrawGasFee();
if (gasZRC20 == zrc20) {
swapAmount = amount - gasFee;
} else {
inputForGas = SwapHelperLib.swapTokensForExactTokens(
systemContract,
zrc20,
gasFee,
gasZRC20,
amount
);
swapAmount = amount - inputForGas;
}
}
uint256 outputAmount = SwapHelperLib.swapExactTokensForTokens(
systemContract,
zrc20,
swapAmount,
params.target,
0
);
if (params.withdraw) {
if (gasZRC20 == params.target) {
IZRC20(gasZRC20).approve(
address(gateway),
outputAmount + gasFee
);
} else {
IZRC20(gasZRC20).approve(address(gateway), gasFee);
IZRC20(params.target).approve(address(gateway), outputAmount);
}
gateway.withdraw(
params.to,
outputAmount,
params.target,
RevertOptions({
revertAddress: address(0),
callOnRevert: false,
abortAddress: address(0),
revertMessage: "",
onRevertGasLimit: 0
})
);
} else {
IWETH9(params.target).transfer(
address(uint160(bytes20(params.to))),
outputAmount
);
}
}
function onRevert(RevertContext calldata revertContext) external override {}
}
The contract defines a Params
struct to store three crucial pieces of
information:
address target
: The address of the target token on ZetaChain.bytes to
: The recipient's address on the destination chain or ZetaChain.bool withdraw
: A flag indicating whether to withdraw the swapped tokens to the destination chain (true
) or keep them on ZetaChain (false
).
When the onCrossChainCall
function is invoked, it decodes the message
parameter to extract the swap details. The decoding process varies depending on
the source chain:
- For Bitcoin: Due to Bitcoin's OP_RETURN size limitations, the contract uses
helper functions like
bytesToAddress
andbytesToBool
to parse the message manually. - For EVM Chains and Solana: The contract uses
abi.decode
to extract thetargetToken
,recipient
, andwithdrawFlag
directly from the message.
Swapping for Gas Tokens
If the withdraw
flag is true
, the contract handles the gas fee required for
the withdrawal on the destination chain:
- It obtains the required
gasFee
and the gas token (gasZRC20
) by callingwithdrawGasFee()
on the target ZRC-20 token. - If the incoming token (
zrc20
) is the same as the gas 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
tokens using
swapTokensForExactTokens
.
Swapping to Target Token
The contract swaps the remaining tokens (swapAmount
) for the target token
specified in params.target
using swapExactTokensForTokens
. This method
returns the amount of the target token received (outputAmount
).
Withdrawal or Transfer
After the swap, the contract either withdraws the tokens to the connected chain
or transfers them on ZetaChain, depending on the withdraw
flag:
- If Withdrawing (
withdraw == true
):- The contract approves the
gateway
to spend the gas tokens and the target tokens. - It calls
withdraw
on the gateway to send the tokens to the recipient on the connected chain.
- The contract approves the
- If Not Withdrawing (
withdraw == false
):- The contract transfers the tokens to the recipient's address on ZetaChain. If the target token is WZETA (Wrapped ZETA), it unwraps it to native ZETA before transferring.
Starting Localnet
Start the local development environment to simulate ZetaChain's behavior by running:
npx hardhat localnet
Deploying the Contract
Compile the contract and deploy it to localnet by running:
yarn deploy --name SwapToAnyToken
You should see output similar to:
🔑 Using account: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
🚀 Successfully deployed contract on localhost.
📜 Contract address: 0x67d269191c92Caf3cD7723F116c85e6E9bf55933
Make sure to provide the systemContractAddress
and gatewayAddress
when
deploying the contract. In localnet, these addresses are the same.
Swapping and Withdrawing Tokens to Connected Chain
To swap tokens from a connected EVM chain and withdraw them to the connected chain, use the following command:
npx hardhat evm-deposit-and-call --network localhost --receiver 0x67d269191c92Caf3cD7723F116c85e6E9bf55933 --amount 1 --types '["address", "bytes", "bool"]' 0x9fd96203f7b22bCF72d9DCb40ff98302376cE09c 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 true
--receiver
is the address of theSwapToAnyToken
contract on ZetaChain.--amount 1
indicates that you want to swap 1 ETH (or the connected chain's native token).--types '["address", "bytes", "bool"]'
defines the ABI types of the message parameters being sent to theonCrossChainCall
function.- The parameters following the types are:
0x9fd96203f7b22bCF72d9DCb40ff98302376cE09c
: The target token address on ZetaChain.0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
: The recipient's address.true
: Thewithdraw
flag set totrue
.
When you execute this command, the script calls the depositAndCall
method on
the connected EVM chain, depositing tokens and sending a message to the
SwapToAnyToken
contract on ZetaChain. The EVM gateway processes the deposit
and emits a Deposited
event.
ZetaChain picks up the event and executes the onCrossChainCall
function of the
SwapToAnyToken
contract. The contract decodes the message, performs the swap,
and withdraws the tokens to the recipient on the connected chain.
Conclusion
In this tutorial, you learned how to enhance the swap contract to support swapping tokens to any token, with the option to withdraw the swapped tokens to the destination chain or keep them on ZetaChain. You also learned how to interact with the contract from connected chains.
Source Code
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)