Build
Tutorials
Swap

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:

  1. Receives a cross-chain call along with native or ERC-20 tokens from a connected chain.

  2. Decodes the message payload to extract:

    • The address of the target token (ZRC-20)
    • The recipient’s address on the destination chain
  3. Queries the withdrawal gas fee required to send the target token back to the destination chain.

  4. Swaps a portion of the incoming tokens for ZRC-20 gas tokens to cover the withdrawal fee using Uniswap v2 pools.

  5. Swaps the remaining balance into the target token.

  6. 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.

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 and cast 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

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.

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 ensures onCall 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:

  1. Quote the destination gas requirement via withdrawGasFee().

  2. Verify the input covers it using a DEX quote:

    uint256 minInput = quoteMinInput(inputToken, targetToken);
    if (amount < minInput) revert InsufficientAmount(...);
  3. If the input isn’t already gasZRC20, swap just enough to buy gasFee:

    inputForGas = SwapHelperLib.swapTokensForExactTokens(
      uniswapRouter, inputToken, gasFee, gasZRC20, amount
    );
  4. 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 raw bytes 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.

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

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:

https://sepolia.basescan.org/tx/0x8def0ff44c0e45803f209bc864123a08a03e6e1fadc5ac6f28f4c17f1463aae9 (opens in a new tab)

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

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:

https://solana.fm/tx/28xsic7NqafyxqDjmqfYL5f6RoHFYLrCKvjSA4UJCXyESmdCb1bVpW3dqT2QJrwV6KmfdWuHrwj8uW4txHZiXLxm?cluster=devnet-solana (opens in a new tab)

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.

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.

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.

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.

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)