Universal Tokens are fully interoperable ERC-20 tokens that can be minted and transferred across any connected chain without wrapping or bridging. Each token retains its supply and metadata across chains, enabling true chain-agnostic fungibility and seamless use in multichain DeFi, payments, and governance systems.
Universal Tokens on ZetaChain are built on the standard OpenZeppelin ERC-20 (opens in a new tab) implementation and use UUPS upgradeable (opens in a new tab) proxy patterns, allowing developers to extend and upgrade token logic safely over time.
Option 1: Create a New Universal Token
Create a new Universal Token project:
npx zetachain@latest new --project token
Install dependencies:
cd token
yarn
forge soldeer update
Compile contracts:
forge build
Option 2: Upgrade an Existing ERC-20 Project
You can upgrade your existing ERC-20 project to become a Universal Token by installing the official standard contracts package:
yarn add @zetachain/standard-contracts
Then, update your contract using the example implementation (opens in a new tab) as a reference—see the commented lines that include Universal Token-specific logic for ZetaChain integration.
This allows your token to support cross-chain minting, transfers, and persistent supply tracking across ZetaChain and connected EVM chains.
Deploy on Testnet
RPC_ETHEREUM=$(zetachain q chains show --chain-id 11155111 -f rpc)
RPC_BASE=$(zetachain q chains show --chain-id 84532 -f rpc)
RPC_ZETACHAIN=$(zetachain q chains show --chain-id 7001 -f rpc)
ZRC20_ETHEREUM=$(zetachain q tokens show -s ETH.ETHSEP -f zrc20)
ZRC20_BASE=$(zetachain q tokens show -s ETH.BASESEP -f zrc20)
GATEWAY_ETHEREUM=0x0c487a766110c85d301d96e33579c5b317fa4995
GATEWAY_BASE=0x0c487a766110c85d301d96e33579c5b317fa4995
GATEWAY_ZETACHAIN=0x6c533f7fe93fae114d0954697069df33c9b74fd7
UNISWAP_ROUTER=0x2ca7d64A7EFE2D62A725E2B35Cf7230D6677FfEe
GAS_LIMIT=1000000
PRIVATE_KEY=...
Deploy contracts on ZetaChain, Base and Ethereum.
ZETACHAIN_TOKEN=$(npx tsx commands deploy \
--rpc $RPC_ZETACHAIN \
--private-key $PRIVATE_KEY \
--name ZetaChainUniversalToken \
--uniswap-router $UNISWAP_ROUTER \
--gateway $GATEWAY_ZETACHAIN \
--gas-limit $GAS_LIMIT | jq -r .contractAddress) && echo $ZETACHAIN_TOKEN
BASE_TOKEN=$(npx tsx commands deploy \
--rpc $RPC_BASE \
--private-key $PRIVATE_KEY \
--name EVMUniversalToken \
--gateway $GATEWAY_BASE \
--gas-limit $GAS_LIMIT | jq -r .contractAddress) && echo $BASE_TOKEN
ETHEREUM_TOKEN=$(npx tsx commands deploy \
--rpc $RPC_ETHEREUM \
--private-key $PRIVATE_KEY \
--name EVMUniversalToken \
--gateway $GATEWAY_ETHEREUM \
--gas-limit $GAS_LIMIT | jq -r .contractAddress) && echo $ETHEREUM_TOKEN
Connect Contracts
After deployment, link the contracts so they can trust each other for
cross-chain communication. Use setConnected
on ZetaChain to register Connected
contracts by their ZRC-20 gas token (used to identify the chain):
cast send $ZETACHAIN_TOKEN 'setConnected(address,bytes)' $ZRC20_BASE $BASE_TOKEN --rpc-url $RPC_ZETACHAIN --private-key $PRIVATE_KEY
cast send $ZETACHAIN_TOKEN 'setConnected(address,bytes)' $ZRC20_ETHEREUM $ETHEREUM_TOKEN --rpc-url $RPC_ZETACHAIN --private-key $PRIVATE_KEY
Then, on each connected chain, use setUniversal
to point back to the Universal
contract on ZetaChain:
cast send $BASE_TOKEN 'setUniversal(address)' $ZETACHAIN_TOKEN --rpc-url $RPC_BASE --private-key $PRIVATE_KEY
cast send $ETHEREUM_TOKEN 'setUniversal(address)' $ZETACHAIN_TOKEN --rpc-url $RPC_ETHEREUM --private-key $PRIVATE_KEY
This ensures only authorized contracts can send and receive token transfers across chains.
Mint on ZetaChain
npx tsx commands mint \
--rpc $RPC_ZETACHAIN \
--private-key $PRIVATE_KEY \
--contract $ZETACHAIN_TOKEN \
--amount 10 | jq -r .mintTransactionHash
Transfer from ZetaChain to Base
Transfer the token from ZetaChain to Base. Gas amount (specified in ZETA) is an estimate. Unused tokens are refunded to the user.
Use ZRC-20 Base ETH as the destination address to specify the chain to which the tokens will be transferred.
npx tsx commands transfer \
--rpc $RPC_ZETACHAIN \
--private-key $PRIVATE_KEY \
--from $ZETACHAIN_TOKEN \
--destination $ZRC20_BASE \
--amount 10 \
--gas-amount 5 | jq -r .transferTransactionHash
zetachain q cctx --hash 0x2ced374831b7612f4f2df98f2d1f30b2fa797ddcc62df0a5883b402b9310fe7a
7001 → 84532 ✅ OutboundMined
CCTX: 0x88ac99d5ce593af62f5e56e2cdeb14797e23fb890933f50d74f1c8944b91b991
Tx Hash: 0x2ced374831b7612f4f2df98f2d1f30b2fa797ddcc62df0a5883b402b9310fe7a (on chain 7001)
Tx Hash: 0x4c0dbfda09e0364a54be7f812a49c677739f0c3a9bc6d2e6bf0c8d5ea4a7903d (on chain 84532)
Sender: 0xE3CA615E4Bd2b106ff51e88A04Ec39A2Afc75212
Receiver: 0x449777033Ff53aD3B4F70C17c31110476E61D2A8
Message: 0000000000000000000000004955a3f38ff86ae92a914445099caa8ea2b9ba32000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000000000000000000000000000004955a3f38ff86ae92a914445099caa8ea2b9ba3200000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000000
Transfer from Base to Ethereum
Let’s move the token again — this time from Base to Ethereum.
npx tsx commands transfer \
--rpc $RPC_BASE \
--private-key $PRIVATE_KEY \
--from $BASE_TOKEN \
--destination $ZRC20_ETHEREUM \
--amount 10 \
--gas-amount 0.005 | jq -r .transferTransactionHash
zetachain q cctx --hash 0x8db12522169485a44ba8490d53f734ac6b7f5da72a6decbe3ae462198a960cee
84532 → 7001 ✅ OutboundMined
CCTX: 0x39d970fc742519c87e67733ffd13315dce907c07e65cf97ba4ffba2e2bf2ceed
Tx Hash: 0x8db12522169485a44ba8490d53f734ac6b7f5da72a6decbe3ae462198a960cee (on chain 84532)
Tx Hash: 0x180f7fecd37f9e097ae5372ec729502933aa15cd6ec078af1efb911370fb33ea (on chain 7001)
Sender: 0x449777033Ff53aD3B4F70C17c31110476E61D2A8
Receiver: 0xE3CA615E4Bd2b106ff51e88A04Ec39A2Afc75212
Message: 00000000000000000000000005ba149a7bd6dc1f937fa9046a9e05c05f3b18b00000000000000000000000004955a3f38ff86ae92a914445099caa8ea2b9ba32000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000004955a3f38ff86ae92a914445099caa8ea2b9ba3200000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000000
Amount: 5000000000000000 Gas tokens
7001 → 11155111 ✅ PendingOutbound (transaction broadcasted to target chain)
CCTX: 0xbbc4e169decc986556b079445dc5055725dff65e9ee66a48613374c1b4e64eda
Tx Hash: 0x39d970fc742519c87e67733ffd13315dce907c07e65cf97ba4ffba2e2bf2ceed (on chain 7001)
Tx Hash: 0x5f973954ab6c6745e770e02f4697309b3e18be60fc53764c7b6deec07cd5fe4b (on chain 11155111)
Sender: 0xE3CA615E4Bd2b106ff51e88A04Ec39A2Afc75212
Receiver: 0xBc1eE0E9452eC2E809FC2dBD0000A7D6095fDfC2
Message: 0000000000000000000000004955a3f38ff86ae92a914445099caa8ea2b9ba32000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000006ba661563e1c0000000000000000000000004955a3f38ff86ae92a914445099caa8ea2b9ba3200000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000000
Amount: 118362341785116 Gas tokens
Status: PendingOutbound, initiating outbound
Source Code
https://github.com/zeta-chain/example-contracts/tree/main/examples/token (opens in a new tab)