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
npm
if 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
forge
andcast
to 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
Setting Up Your Environment
Start by creating a new ZetaChain project using the CLI:
zetachain new --project swap
Install dependencies:
cd swap
yarn
Pull Solidity dependencies using Foundry’s package manager:
forge soldeer update
Compile the contract:
forge build
This 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 onlyGateway
onlyGateway
ensuresonCall
is invoked exclusively by the Gateway.MessageContext
carries 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();
gasZRC20
is the ZRC-20 that represents the destination chain’s gas token.gasFee
is 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
.
Decoding the message payload
When a cross-chain call reaches your universal app, any extra parameters are passed as an ABI-encoded payload. For the swap contract, this payload contains three values:
(address targetToken, bytes recipient, bool withdrawFlag)
targetToken
: The ZRC-20 token address for the asset to deliver after the swap.recipient
: The destination address in rawbytes
form, which works for any supported chain (EVM, Solana, etc.).withdrawFlag
: Controls whether the swapped tokens are sent to another chain (true
) or transferred locally on ZetaChain (false
).
Inside onCall
, you decode the payload like this:
(address targetToken, bytes memory recipient, bool withdrawFlag) =
abi.decode(message, (address, bytes, bool));
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
To deploy the Swap contract to ZetaChain testnet, run the following command:
UNIVERSAL=$(npx tsx commands deploy --private-key $PRIVATE_KEY | jq -r .contractAddress) && echo $UNIVERSAL
This deploys the precompiled contract using the specified private key and outputs the deployed address.
The deployment script automatically uses the correct Gateway and Uniswap router addresses for testnet.
Once complete, the UNIVERSAL
environment variable will contain the address of
your deployed Swap contract on testnet. You’ll reference this address when
triggering swaps from connected chains.
Get your EVM sender address from the private key:
RECIPIENT=$(cast wallet address $PRIVATE_KEY) && echo $RECIPIENT
Query the ZRC-20 address that represents ETH from Ethereum Sepolia:
ZRC20_ETHEREUM_ETH=$(zetachain q tokens show --symbol sETH.SEPOLIA -f zrc20) && echo $ZRC20_ETHEREUM_ETH
Swap from Base to Ethereum
To initiate a swap from Base to Ethereum, run:
npx zetachain evm deposit-and-call \
--chain-id 84532 \
--amount 0.001 \
--types address bytes bool \
--receiver $UNIVERSAL \
--values $ZRC20_ETHEREUM_ETH $RECIPIENT true
This sends 0.001 ETH from Base Sepolia into ZetaChain. Under the hood, this
command invokes the depositAndCall
function on the Base Gateway. The Gateway
wraps the incoming ETH as ZRC-20 Base ETH and delivers it to the universal swap
contract on ZetaChain along with the encoded payload.
The contract receives the Base ETH as a ZRC-20 token, performs the swap to ZRC-20 Ethereum ETH using Uniswap v2 liquidity on ZetaChain, and then withdraws the swapped tokens to your address on Ethereum Sepolia.
This entire flow is completed as a single cross-chain transaction. You don’t need to pre-fund gas on the destination chain or interact with bridges or routers—everything is orchestrated by the universal contract on ZetaChain.
Transaction on Base:
You can inspect the full cross-chain context with:
zetachain query cctx --hash 0x8def0ff44c0e45803f209bc864123a08a03e6e1fadc5ac6f28f4c17f1463aae9
This will show the inbound delivery from Base to ZetaChain, and the outbound delivery from ZetaChain to Ethereum Sepolia, including transaction hashes, sender and recipient addresses, and token amounts.
84532 → 7001 ✅ OutboundMined
CCTX: 0x11ff9e850f0974de3b23f5347feb8684a88c6124e972da725b854031a632ad37
Tx Hash: 0x8def0ff44c0e45803f209bc864123a08a03e6e1fadc5ac6f28f4c17f1463aae9 (on chain 84532)
Tx Hash: 0xe5e5f72154140f63673d8260ed638c7dbb8c5b6901bc19b1e93651fdb35c6a00 (on chain 7001)
Sender: 0x4955a3F38ff86ae92A914445099caa8eA2B9bA32
Receiver: 0x92ae647a9D8d09D58514037d6535ab93a2A8138f
Message: 00000000000000000000000005ba149a7bd6dc1f937fa9046a9e05c05f3b18b00000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000144955a3f38ff86ae92a914445099caa8ea2b9ba32000000000000000000000000
Amount: 1000000000000000 Gas tokens
7001 → 11155111 ✅ OutboundMined
CCTX: 0xfb6f6e1cb94e646fa7a8123082054aea08fa9a65a5f07e1e0dc48463ebfaf9dd
Tx Hash: 0x11ff9e850f0974de3b23f5347feb8684a88c6124e972da725b854031a632ad37 (on chain 7001)
Tx Hash: 0x285a47661b2216ff6d76cd811ca3e3de622b6927f9da94b1cb706f88ad86ef38 (on chain 11155111)
Sender: 0x92ae647a9D8d09D58514037d6535ab93a2A8138f
Receiver: 0x4955a3F38ff86ae92A914445099caa8eA2B9bA32
Amount: 17091458311542 Gas tokens
Swap from Solana to Ethereum
To initiate a swap from Solana to Ethereum Sepolia, run:
npx zetachain solana deposit-and-call \
--recipient $UNIVERSAL \
--types address bytes bool \
--values $ZRC20_ETHEREUM_ETH $RECIPIENT true \
--chain-id 901 \
--private-key $SOLANA_PRIVATE_KEY \
--amount 0.01
This sends 0.01 SOL to the universal swap contract on ZetaChain. The Solana Gateway locks the native SOL and delivers its ZRC-20 representation to the contract along with the swap payload.
The contract swaps the ZRC-20 SOL for ZRC-20 Ethereum ETH and withdraws the
resulting amount to the RECIPIENT
on Ethereum Sepolia. The recipient address
is passed as raw bytes, which allows the same universal contract to serve
cross-chain swaps from both EVM and non-EVM chains like Solana.
Transaction on Solana:
To inspect the full cross-chain context:
npx zetachain query cctx --hash 28xsic7NqafyxqDjmqfYL5f6RoHFYLrCKvjSA4UJCXyESmdCb1bVpW3dqT2QJrwV6KmfdWuHrwj8uW4txHZiXLxm
901 → 7001 ✅ OutboundMined
CCTX: 0xd6e73b3ce77bc16bfaf1b8449b46991476ea7a8cec7ab1f508cd14aaf972028b
Tx Hash: 28xsic7NqafyxqDjmqfYL5f6RoHFYLrCKvjSA4UJCXyESmdCb1bVpW3dqT2QJrwV6KmfdWuHrwj8uW4txHZiXLxm (on chain 901)
Tx Hash: 0x91001d6539c04bf3629d611543ccf8f819a9f7fdf1f1c76bfd695b7e46750bbe (on chain 7001)
Sender: AS48jKNQsDGkEdDvfwu1QpqjtqbCadrAq9nGXjFmdX3Z
Receiver: 0x92ae647a9D8d09D58514037d6535ab93a2A8138f
Message: 00000000000000000000000005ba149a7bd6dc1f937fa9046a9e05c05f3b18b00000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000144955a3f38ff86ae92a914445099caa8ea2b9ba32000000000000000000000000
Amount: 10000000 Gas tokens
7001 → 11155111 ✅ OutboundMined
CCTX: 0xc718334a07bac99c6ac21bc8a69bf743eff5c391a3998d957bd51af6240b6d07
Tx Hash: 0xd6e73b3ce77bc16bfaf1b8449b46991476ea7a8cec7ab1f508cd14aaf972028b (on chain 7001)
Tx Hash: 0x791aade1bb72cf516f9a4c6d774237f7736fbe49430aa669fb82b34b27623a85 (on chain 11155111)
Sender: 0x92ae647a9D8d09D58514037d6535ab93a2A8138f
Receiver: 0x4955a3F38ff86ae92A914445099caa8eA2B9bA32
Amount: 8645129169842 Gas tokens
Swap from Bitcoin to Ethereum
You can also trigger the same swap flow from Bitcoin. This command sends 0.05 BTC via inscription, delivers the ZRC-20 representation of BTC to your universal contract on ZetaChain with the encoded payload, swaps to ZRC-20 Ethereum ETH, and withdraws to your address on Ethereum Sepolia:
Before running the command, set PRIVATE_KEY_BTC
to your Bitcoin private key.
zetachain bitcoin inscription deposit-and-call \
--private-key $PRIVATE_KEY_BTC \
--receiver $UNIVERSAL \
--types address bytes bool \
--values $ZRC20_ETHEREUM_ETH $RECIPIENT true \
--amount 0.05
Once the Bitcoin transaction is observed and processed, your contract executes the swap and withdrawal in a single cross-chain flow.
Option 2: Deploy on Localnet
Query the Uniswap router:
UNISWAP_ROUTER=$(jq -r '.["31337"].contracts[] | select(.contractType == "uniswapRouterInstance") | .address' ~/.zetachain/localnet/registry.json) && echo $UNISWAP_ROUTER
Query the Gateway address:
GATEWAY_ZETACHAIN=$(jq -r '.["31337"].contracts[] | select(.contractType == "gateway") | .address' ~/.zetachain/localnet/registry.json) && echo $GATEWAY_ZETACHAIN
Get the localnet private key:
PRIVATE_KEY=$(jq -r '.private_keys[0]' ~/.zetachain/localnet/anvil.json) && echo $PRIVATE_KEY
Deploy the contract:
UNIVERSAL=$(npx tsx commands/index.ts deploy \
--private-key $PRIVATE_KEY \
--rpc http://localhost:8545 \
--gateway $GATEWAY_ZETACHAIN \
--uniswap-router $UNISWAP_ROUTER | jq -r .contractAddress) && echo $UNIVERSAL
This will deploy the contract to your Localnet and store the deployed address in
UNIVERSAL
.
Swap from Ethereum to BNB
To perform a cross-chain swap from Ethereum to BNB on Localnet, first set the relevant variables.
Get the Gateway contract address for Ethereum:
GATEWAY_ETHEREUM=$(jq -r '.["11155112"].contracts[] | select(.contractType == "gateway") | .address' ~/.zetachain/localnet/registry.json) && echo $GATEWAY_ETHEREUM
Get the ZRC-20 address that represents the BNB gas token:
ZRC20_BNB=$(jq -r '."98".chainInfo.gasZRC20' ~/.zetachain/localnet/registry.json) && echo $ZRC20_BNB
Get your local sender address from the private key:
RECIPIENT=$(cast wallet address $PRIVATE_KEY) && echo $RECIPIENT
Then trigger the swap:
npx zetachain evm deposit-and-call \
--rpc http://localhost:8545 \
--chain-id 11155112 \
--gateway $GATEWAY_ETHEREUM \
--amount 0.001 \
--types address bytes bool \
--receiver $UNIVERSAL \
--private-key $PRIVATE_KEY \
--values $ZRC20_BNB $RECIPIENT true
This sends 0.001 ETH from Ethereum to ZetaChain on Localnet, where it will be swapped for ZRC-20 BNB and then withdrawn to your address on the BNB localnet chain.
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)