Introduction
In this tutorial, you'll build a Universal App on ZetaChain that enables seamless cross-chain token swaps. This app allows users to send native gas tokens or ERC-20 tokens from a connected chain and receive a different token on another chain, all in a single transaction. For example, a user can swap USDC on Ethereum for BTC on Bitcoin, without interacting with bridges or centralized exchanges.
Youโll learn how to:
- Create a Universal App that performs token swaps across chains
- Deploy it to ZetaChain
- Trigger a cross-chain swap from a connected EVM chain
The swap logic is implemented as a smart contract deployed on ZetaChain,
conforming to the UniversalContract interface. This makes the contract
callable from any connected chain through the Gateway. When tokens are sent from
a connected chain, they arrive on ZetaChain as ZRC-20
tokens, a native representation of external assets. ZRC-20 tokens preserve the
original assetโs properties while enabling programmable behavior on ZetaChain,
including cross-chain withdrawals.
The Swap contract performs the following steps:
-
Receives a cross-chain call along with native or ERC-20 tokens from a connected chain.
-
Decodes the message payload to extract:
- The address of the target token (ZRC-20)
- The recipientโs address on the destination chain
-
Queries the withdrawal gas fee required to send the target token back to the destination chain.
-
Swaps a portion of the incoming tokens for ZRC-20 gas tokens to cover the withdrawal fee using Uniswap v2 pools.
-
Swaps the remaining balance into the target token.
-
Withdraws the swapped tokens to the recipient on the destination chain.
This approach allows users to initiate complex multi-chain operations with a single transaction from any supported chain, abstracting away the complexity of liquidity routing, gas payments, and execution across chains.
Prerequisites
Before you begin, make sure your development environment includes the following tools:
- Node.js (opens in a new tab) (v18 or later): Required for running scripts and managing project dependencies.
- Yarn (opens in a new tab): A package manager for installing project
dependencies. You may use
npmif preferred. - Git (opens in a new tab): Used to clone repositories and track changes.
- jq (opens in a new tab): A lightweight command-line tool for parsing and querying JSON data. Itโs especially useful for extracting values from localnet outputs.
- Foundry (opens in a new tab): A fast, portable toolkit for Ethereum
application development. Youโll use
forgeandcastto compile and deploy contracts. - ZetaChain CLI: The command-line interface for interacting with ZetaChainโs localnet and connected chain gateways.
To install the CLI globally:
npm install -g zetachain@latest๐ก If you prefer not to install it globally, you can use
npx zetachain@latestthroughout the tutorial instead.
Setting Up Your Environment
Start by creating a new ZetaChain project using the CLI:
zetachain new --project swapInstall dependencies:
cd swap
yarnPull Solidity dependencies using Foundryโs package manager:
forge soldeer updateThis will set up a working environment with Foundry and ZetaChain CLI support, and prepare your project for local deployment and testing.
Understanding the Swap Contract
The Swap contract is a Universal App deployed on ZetaChain. It enables users
to perform token swaps across blockchains with a single cross-chain call. Tokens
are received as ZRC-20s, optionally swapped using Uniswap v2 liquidity, and
withdrawn back to a connected chain.
Universal App entrypoint: on_call
The contract is deployed on ZetaChain and implements UniversalContract,
exposing a single entrypoint. Cross-chain deliveries are executed only via the
Gateway, so the call surface stays minimal and trusted.
function onCall(
MessageContext calldata context,
address zrc20,
uint256 amount,
bytes calldata message
) external onlyGatewayonlyGatewayensuresonCallis invoked exclusively by the Gateway.MessageContextcarries the origin chain (context.chainID) and the original caller (context.sender). Treat this as the canonical source identity.
Asset model: ZRC-20
Assets arriving from connected chains are represented as ZRC-20s on ZetaChain.
In onCall, zrc20 is the input token and amount is how much was delivered.
To send assets out to another chain, the contract approves the Gateway to spend
specific ZRC-20 amounts and then calls withdraw.
Two interfaces are central:
Withdrawal gas quote for the destination chain:
(address gasZRC20, uint256 gasFee) = IZRC20(targetToken).withdrawGasFee();gasZRC20is the ZRC-20 that represents the destination chainโs gas token.gasFeeis the amount required to execute on the destination chain.
Withdrawal to a connected chain (burn ZRC-20, release native on the other side):
IZRC20(gasZRC20).approve(address(gateway), gasFee);
IZRC20(params.target).approve(address(gateway), out);
gateway.withdraw(
abi.encodePacked(params.to), // chain-agnostic recipient (bytes)
out, // amount of target token
params.target, // ZRC-20 to withdraw
revertOptions // failure handling
);Funding destination execution from the userโs input
The app provisions destination gas out of the input, so users donโt need to pre-hold gas on the target chain.
Flow:
-
Quote the destination gas requirement via
withdrawGasFee(). -
Verify the input covers it using a DEX quote:
uint256 minInput = quoteMinInput(inputToken, targetToken); if (amount < minInput) revert InsufficientAmount(...); -
If the input isnโt already
gasZRC20, swap just enough to buygasFee:inputForGas = SwapHelperLib.swapTokensForExactTokens( uniswapRouter, inputToken, gasFee, gasZRC20, amount ); -
Swap the remainder into the target token:
out = SwapHelperLib.swapExactTokensForTokens( uniswapRouter, inputToken, amount - inputForGas, targetToken, 0 );
quoteMinInput() uses Uniswap v2 pricing (getAmountsIn) to determine the
minimum input necessary to cover the gas fee.
Chain-agnostic addresses
Recipients (and senders in events) are carried as raw bytes, not address, so
the same contract can serve EVM, Bitcoin, Solana, etc. For cross-chain withdraw:
pass bytes directly to gateway.withdraw.
Compact messages for Bitcoin
With the 80-byte OP_RETURN constraint, the contract supports a compact payload
when context.chainID corresponds to Bitcoin (mainnet 8332, testnet 18334):
if (context.chainID == BITCOIN_TESTNET || context.chainID == BITCOIN) {
if (message.length < 41) revert InvalidMessageLength();
// [0..19] target ZRC-20
params.target = BytesHelperLib.bytesToAddress(message, 0);
// [20..len-2] recipient bytes (variable)
params.to = new bytes(message.length - 21);
for (uint256 i = 0; i < message.length - 21; i++) {
params.to[i] = message[20 + i];
}
// [len-1] withdraw flag
params.withdraw = BytesHelperLib.bytesToBool(message, message.length - 1);
} else {
(address targetToken, bytes memory recipient, bool withdrawFlag) =
abi.decode(message, (address, bytes, bool));
params.target = targetToken;
params.to = recipient;
params.withdraw = withdrawFlag;
}Revert with RevertOptions and onRevert
If the destination call/transfer fails, the Gateway triggers onRevert with a
RevertContext. The contract pre-encodes a small recovery message in
revertMessage (original sender and original input token), then executes a
deterministic refund:
function onRevert(RevertContext calldata context) external onlyGateway {
(bytes memory sender, address zrc20) =
abi.decode(context.revertMessage, (bytes, address));
(uint256 out,,) = handleGasAndSwap(
context.asset, context.amount, zrc20, true
);
gateway.withdraw(
sender, // chain-agnostic refund address
out,
zrc20,
RevertOptions({
revertAddress: address(bytes20(sender)), // best-effort for EVM
callOnRevert: false,
abortAddress: address(0),
revertMessage: "",
onRevertGasLimit: gasLimit
})
);
}The result is a consistent refund flow across chains, governed by the app.
Swapping using liquidity pools
Universal contracts can route through any DEX/AMM available on ZetaChain. Uniswap v2 is used here purely as an example via SwapHelperLib, which wraps common router calls.
// Buy exact destination gas
SwapHelperLib.swapTokensForExactTokens(
uniswapRouter, inputToken, gasFee, gasZRC20, amount
);
// Swap remainder to target
SwapHelperLib.swapExactTokensForTokens(
uniswapRouter, inputToken, swapAmount, targetToken, 0
);Youโre free to replace uniswapRouter and the helper calls with any DEX interface or custom routing logicโonly the ZRC-20 token flow and the Gateway withdraw semantics are assumed by the rest of the contract.
Option 1: Deploy on Testnet
npx hardhat compile --force
npx hardhat deploy \
--gateway 0x6c533f7fe93fae114d0954697069df33c9b74fd7 \
--uniswap-router 0x2ca7d64A7EFE2D62A725E2B35Cf7230D6677FfEe \
--network zeta_testnet๐ Using account: 0x4955a3F38ff86ae92A914445099caa8eA2B9bA32
๐ Successfully deployed contract on zeta_testnet.
๐ Contract address: 0x162CefCe314726698ac1Ee5895a6c392ba8e20d3Swap from Base Sepolia to Polygon Amoy
npx hardhat evm-deposit-and-call \
--receiver 0x162CefCe314726698ac1Ee5895a6c392ba8e20d3 \
--amount 0.001 \
--network base_sepolia \
--gas-price 10000000000 \
--gateway-evm 0x0c487a766110c85d301d96e33579c5b317fa4995 \
--types '["address", "bytes", "bool"]' 0x777915D031d1e8144c90D025C594b3b8Bf07a08d 0x4955a3F38ff86ae92A914445099caa8eA2B9bA32 trueTransaction on Base:
Incoming transaction from Base to ZetaChain:
Outgoing transaction from ZetaChain to Polygon:
Transaction on Polygon:
Swap from Solana SOL to Base Sepolia ETH
npx hardhat solana-deposit-and-call \
--amount 0.1 \
--recipient 0x162CefCe314726698ac1Ee5895a6c392ba8e20d3 \
--types '["address", "bytes", "bool"]' 0x236b0DE675cC8F46AE186897fCCeFe3370C9eDeD 0x4955a3F38ff86ae92A914445099caa8eA2B9bA32 trueTransaction on Solana:
Incoming transaction from Solana to ZetaChain:
Outgoing transaction from ZetaChain to Base:
Transaction on Base:
Option 2: Deploy on Localnet
Start the local development environment to simulate ZetaChain's behavior by running:
yarn zetachain localnet startCompile the contract and deploy it to localnet by running:
npx hardhat deploy \
--name Swap \
--network localhost \
--gateway 0x5FC8d32690cc91D4c39d9d3abcBD16989F875707 \
--uniswap-router 0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0You should see output similar to:
๐ Using account: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
๐ Successfully deployed contract on localhost.
๐ Contract address: =0x0355B7B8cb128fA5692729Ab3AAa199C1753f726Swapping Gas Tokens for ERC-20 Tokens
To swap gas tokens for ERC-20 tokens, run the following command:
npx hardhat evm-swap \
--network localhost \
--receiver 0x0355B7B8cb128fA5692729Ab3AAa199C1753f726 \
--amount 0.1 \
--gateway-evm 0xA51c1fc2f0D1a1b8494Ed1FE312d7C3a78Ed91C0 \
--target 0x05BA149A7bd6dC1F937fA9046A9e05C05f3b18b0 \
--skip-checks \
--recipient 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266This script deposits tokens into the gateway on a connected EVM chain 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 that will handle the swap. The --amount 1 option indicates that
you want to swap 1 ETH. --target is the ZRC-20 address of the destination
token (in this example, it's ZRC-20 USDC).
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.
ZetaChain then picks up the event and executes the onCall function of the Swap
contract with the provided message.
The Swap contract decodes the message, identifies the target ERC-20 token and recipient, and initiates the swap logic.
Finally, the EVM chain receives the withdrawal request, and the swapped ERC-20 tokens are transferred to the recipient's address:
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-swap \
--network localhost \
--receiver 0x0355B7B8cb128fA5692729Ab3AAa199C1753f726 \
--amount 0.1 \
--target 0x65a45c57636f9BcCeD4fe193A602008578BcA90b \
--gateway-evm 0xA51c1fc2f0D1a1b8494Ed1FE312d7C3a78Ed91C0 \
--skip-checks \
--erc20 0xE6E340D132b5f46d1e472DebcD681B2aBc16e57E \
--recipient 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266Here, the --erc20 option specifies the ERC-20 token address you're swapping
from on the source chain. 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.
ZetaChain picks up the event and executes the onCall function of the Swap
contract:
The Swap contract decodes the message, identifies the target gas token and recipient, and initiates the swap logic.
The EVM chain then receives the withdrawal request, and the swapped gas tokens are transferred to the recipient's address.
Swapping SUI for Solana SOL
npx hardhat localnet:sui-deposit-and-call \
--mnemonic "grape subway rack mean march bubble carry avoid muffin consider thing street" \
--gateway 0x36f26de84772c7dc8d4b9e291c92c2b067c448e14936aa7bad546ed9a5f348d3 \
--module 0x7418f0a63ff1d1fdc0b4dd2a1b7fc1760c62e7bc7609e1fb71ec30f0fbfb0a00 \
--receiver 0x0355B7B8cb128fA5692729Ab3AAa199C1753f726 \
--amount 1000000 \
--types '["address", "bytes", "bool"]' 0x777915D031d1e8144c90D025C594b3b8Bf07a08d DrexsvCMH9WWjgnjVbx1iFf3YZcKadupFmxnZLfSyotd trueSwapping Solana SOL for SUI
npx hardhat localnet:solana-deposit-and-call \
--receiver 0x0355B7B8cb128fA5692729Ab3AAa199C1753f726 \
--amount 0.0001 \
--types '["address", "bytes", "bool"]' 0xe573a6e11f8506620F123DBF930222163D46BCB6 0x2fec3fafe08d2928a6b8d9a6a77590856c458d984ae090ccbd4177ac13729e65 trueBefore swapping to Sui, make sure you have either deposited tokens from Sui or deposited and made a call from Sui first. Otherwise, there will be no tokens available in custody on Sui.
Swapping Solana SPL for SUI
npx hardhat localnet:solana-deposit-and-call \
--receiver 0x0355B7B8cb128fA5692729Ab3AAa199C1753f726 \
--mint 2rJE9EiWx7hrCKiBsAgtmTZbcuDRESkEvw9ZaAwX1YHN \
--to 7nHtQCVaUMRkwiUhDmoJUJ2p1WMatoz3k22C3xGvid64 \
--from DdTS9SENotAj5z96TQX5pRKd11TkvHKaSneK1GnCCrKD \
--amount 0.1 \
--types '["address", "bytes", "bool"]' 0xe573a6e11f8506620F123DBF930222163D46BCB6 0x2fec3fafe08d2928a6b8d9a6a77590856c458d984ae090ccbd4177ac13729e65 trueSwapping SUI for Solana SPL
npx hardhat localnet:sui-deposit-and-call \
--mnemonic "grape subway rack mean march bubble carry avoid muffin consider thing street" \
--gateway 0xb58507c84f8247d2866b393fbd11467211c89e4e4935d01714a868b2dde493ae \
--module 0xb98bb74211a4f8463d87b61ace7d36f703370dae989baf8c620131d28525a899 \
--receiver 0x0355B7B8cb128fA5692729Ab3AAa199C1753f726 \
--amount 100000 \
--types '["address", "bytes", "bool"]' 0xfC9201f4116aE6b054722E10b98D904829b469c3 DrexsvCMH9WWjgnjVbx1iFf3YZcKadupFmxnZLfSyotd trueConclusion
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)