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.
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 {
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;
bool withdraw;
}
function onCall(
MessageContext calldata context,
address zrc20,
uint256 amount,
bytes calldata message
) external onlyGateway {
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(
uniswapRouter,
inputToken,
gasFee,
gasZRC20,
amount
);
swapAmount = amount - inputForGas;
}
}
uint256 outputAmount = SwapHelperLib.swapExactTokensForTokens(
uniswapRouter,
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 onlyGateway {}
}
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 onCall
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 onCall
, 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-any:localnet
After deployment, you should see an output similar to this:
🔑 Using account: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
🚀 Successfully deployed contract on localhost.
📜 Contract address: 0x84eA74d481Ee0A5332c457a4d796187F6Ba67fEB
Swap and Withdraw 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 swap-from-evm --network localhost --receiver 0x84eA74d481Ee0A5332c457a4d796187F6Ba67fEB --amount 1 --target 0x9fd96203f7b22bCF72d9DCb40ff98302376cE09c --recipient 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 --withdraw true
- EVM gateway is called with 1 native gas token (ETH) and a message that contains target ZRC-20 token address, receiver address and a boolean that indicates to withdraw a token to the destination chain or not.
onCall
is called- If
withdraw
is:- true, withdraw the ZRC-20 to the destination chain as a native token
- false, send target ZRC-20 to the recipient
In the command above the withdraw
is true
, so the target ZRC-20 token will
be transferred to the destination chain.
Swap Tokens Without Withdrawing
If you want to swap tokens and keep them on ZetaChain rather than withdrawing
them, set the withdraw
flag.
npx hardhat swap-from-evm --network localhost --receiver 0x84eA74d481Ee0A5332c457a4d796187F6Ba67fEB --amount 1 --target 0x9fd96203f7b22bCF72d9DCb40ff98302376cE09c --recipient 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 --withdraw false
In the command above the withdraw
is false
, so the target ZRC-20 token will
be transferred to the recipient on ZetaChain.
Swap on ZetaChain and Withdraw Tokens to Connected Chain
To swap a ZRC-20 token for another ZRC-20 on ZetaChain and withdraw to a connected chain, run:
npx hardhat swap-from-zetachain --network localhost --contract 0x84eA74d481Ee0A5332c457a4d796187F6Ba67fEB --amount 1 --target 0x2ca7d64A7EFE2D62A725E2B35Cf7230D6677FfEe --recipient 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 --zrc20 0x9fd96203f7b22bCF72d9DCb40ff98302376cE09c --withdraw true
Swap on ZetaChain Without Withdrawing
To swap a ZRC-20 token for another ZRC-20 on ZetaChain and transfer it to a recipient on ZetaChain, run:
npx hardhat swap-from-zetachain --network localhost --contract 0x84eA74d481Ee0A5332c457a4d796187F6Ba67fEB --amount 1 --target 0x2ca7d64A7EFE2D62A725E2B35Cf7230D6677FfEe --recipient 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 --zrc20 0x9fd96203f7b22bCF72d9DCb40ff98302376cE09c --withdraw false
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)]