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.
Setting Up Your Environment
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
Understanding the SwapToAnyToken Contract
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.
Starting Localnet
To simulate ZetaChain’s behavior locally, start the local development environment by running:
npx hardhat localnet
Deploying the Contract
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.
Swapping and Withdrawing Tokens to Connected Chain
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
.
Swapping Tokens Without Withdrawing
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.
Conclusion
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.
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)]