Build
Contract Standards
Universal NFT

The Universal NFT standard enables non-fungible tokens (ERC-721 NFT) to be minted on any chain and seamlessly transferred between connected chains.

When transferring tokens between chains, a token is burned on the source chain. The token's metadata and information are sent in a message to the token contract on the destination chain, where a corresponding token is minted.

The project consists of two ERC-721 contracts: Universal and Connected.

Universal contract is deployed on ZetaChain. The contract is used to:

  • Mint NFTs on ZetaChain
  • Transfer NFTs from ZetaChain to a connected chain
  • Handle incoming NFT transfers from connected chain to ZetaChain
  • Handle NFT transfers between connected chains

Connected contract is deployed one or more connected EVM chains. The contract is used to:

  • Mint an NFT on a connected chain
  • Transfer NFT to another connected chain or ZetaChain
  • Handling incoming NFT transfers from ZetaChain or another connected chain

A Universal contract deployment on ZetaChain is required, while Connected contracts can be deployed as needed to enable token transfers for specific chains.

A universal NFT can be minted on any chain: ZetaChain or any connected EVM chain. When an NFT is minted, it gets assigned a persistent token ID that is unique across all chains. When an NFT is transferred between chains, the token ID remains the same.

An NFT can be transferred from ZetaChain to a connected chain, from a connected chain to ZetaChain and between connected chains. ZetaChain acts as a hub for cross-chain transactions, so all transfers go through ZetaChain. For example, when you transfer an NFT from Ethereum to BNB, two cross-chain transactions are initiated: Ethereum → ZetaChain → BNB. This doesn't impact the transfer time or costs, but makes it easier to connect any number of chains as the number of connections grows linearly.

Cross-chain NFT transfers are capable of handling reverts. If the transfer fails on the destination chain, an NFT will be returned to the original sender on the source chain.

NFT contracts only accept cross-chain calls from trusted NFT contracts. Each contract on a connected chain stores a universal contract address — an address of the Universal contract on ZetaChain. The Universal contract stores a list of connected contracts on connected chains. This ensures that only the contracts from the same NFT collection can participate in the cross-chain transfer.

https://excalidraw.com/#json=dQJisu_uJ0N8T6IPi2m0E,PJU63ktFfbi1WsfAXsompA

Thirdweb (opens in a new tab) is a web3 development platform that enables developers to deploy and interact with smart contracts across EVM-compatible blockchains.

yarn add @zetachain/standard-contracts@v1.0.0-rc2

Contract on ZetaChain:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
 
import "@zetachain/standard-contracts/contracts/nft/contracts/zetachain/UniversalNFT.sol";
 
contract ZetaChainUniversalNFT is UniversalNFT {}

Contract on an EVM chain:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
 
import "@zetachain/standard-contracts/contracts/nft/contracts/evm/UniversalNFT.sol";
 
contract EVMUniversalNFT is UniversalNFT {}

UniversalNFT is an upgradeable contract that uses OpenZeppelin UUPSUpgradeable (opens in a new tab). Instead of a constructor it uses the initialize function.

If you already have an ERC-721 contract you want make universal or you prefer to start from an OpenZeppelin template, you can import Universal NFT functionality with just a few lines of code:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
 
import {ERC721Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC721/ERC721Upgradeable.sol";
import {ERC721BurnableUpgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC721/extensions/ERC721BurnableUpgradeable.sol";
import {ERC721EnumerableUpgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC721/extensions/ERC721EnumerableUpgradeable.sol";
import {ERC721PausableUpgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC721/extensions/ERC721PausableUpgradeable.sol";
import {ERC721URIStorageUpgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC721/extensions/ERC721URIStorageUpgradeable.sol";
import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
 
// Import UniversalNFTCore for universal NFT functionality
import "@zetachain/standard-contracts/contracts/nft/contracts/zetachain/UniversalNFTCore.sol";
 
contract UniversalNFT is
    Initializable, // Allows upgradeable contract initialization
    ERC721Upgradeable, // Base ERC721 implementation
    ERC721URIStorageUpgradeable, // Enables metadata URI storage
    ERC721EnumerableUpgradeable, // Provides enumerable token support
    ERC721PausableUpgradeable, // Allows pausing token operations
    OwnableUpgradeable, // Restricts access to owner-only functions
    ERC721BurnableUpgradeable, // Adds burnable functionality
    UUPSUpgradeable, // Supports upgradeable proxy pattern
    UniversalNFTCore // Custom core for additional logic
{
    uint256 private _nextTokenId; // Track next token ID for minting
 
    /// @custom:oz-upgrades-unsafe-allow constructor
    constructor() {
        _disableInitializers();
    }
 
    function initialize(
        address initialOwner,
        string memory name,
        string memory symbol,
        address payable gatewayAddress, // Include EVM gateway address
        uint256 gas, // Set gas limit for universal NFT calls
        address uniswapRouterAddress // Uniswap v2 router address for gas token swaps
    ) public initializer {
        __ERC721_init(name, symbol);
        __ERC721Enumerable_init();
        __ERC721URIStorage_init();
        __ERC721Pausable_init();
        __Ownable_init(initialOwner);
        __ERC721Burnable_init();
        __UUPSUpgradeable_init();
        __UniversalNFTCore_init(gatewayAddress, gas, uniswapRouterAddress); // Initialize universal NFT core
    }
 
    function safeMint(
        address to,
        string memory uri
    ) public onlyOwner whenNotPaused {
        // Generate globally unique token ID, feel free to supply your own logic
        uint256 hash = uint256(
            keccak256(
                abi.encodePacked(address(this), block.number, _nextTokenId++)
            )
        );
 
        uint256 tokenId = hash & 0x00FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF;
 
        _safeMint(to, tokenId);
        _setTokenURI(tokenId, uri);
    }
 
    // The following functions are overrides required by Solidity.
 
    function _update(
        address to,
        uint256 tokenId,
        address auth
    )
        internal
        override(
            ERC721Upgradeable,
            ERC721EnumerableUpgradeable,
            ERC721PausableUpgradeable
        )
        returns (address)
    {
        return super._update(to, tokenId, auth);
    }
 
    function _increaseBalance(
        address account,
        uint128 value
    ) internal override(ERC721Upgradeable, ERC721EnumerableUpgradeable) {
        super._increaseBalance(account, value);
    }
 
    function tokenURI(
        uint256 tokenId
    )
        public
        view
        override(
            ERC721Upgradeable,
            ERC721URIStorageUpgradeable,
            UniversalNFTCore // Include UniversalNFTCore for URI overrides
        )
        returns (string memory)
    {
        return super.tokenURI(tokenId);
    }
 
    function supportsInterface(
        bytes4 interfaceId
    )
        public
        view
        override(
            ERC721Upgradeable,
            ERC721EnumerableUpgradeable,
            ERC721URIStorageUpgradeable,
            UniversalNFTCore // Include UniversalNFTCore for interface overrides
        )
        returns (bool)
    {
        return super.supportsInterface(interfaceId);
    }
 
    function _authorizeUpgrade(
        address newImplementation
    ) internal override onlyOwner {}
 
    function pause() public onlyOwner {
        _pause();
    }
 
    function unpause() public onlyOwner {
        _unpause();
    }
 
    receive() external payable {} // Receive ZETA to pay for gas
}
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
 
import {ERC721Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC721/ERC721Upgradeable.sol";
import {ERC721BurnableUpgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC721/extensions/ERC721BurnableUpgradeable.sol";
import {ERC721EnumerableUpgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC721/extensions/ERC721EnumerableUpgradeable.sol";
import {ERC721PausableUpgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC721/extensions/ERC721PausableUpgradeable.sol";
import {ERC721URIStorageUpgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC721/extensions/ERC721URIStorageUpgradeable.sol";
import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
 
// Import UniversalNFTCore for universal NFT functionality
import "@zetachain/standard-contracts/contracts/nft/contracts/evm/UniversalNFTCore.sol";
 
contract UniversalNFT is
    Initializable,
    ERC721Upgradeable,
    ERC721EnumerableUpgradeable,
    ERC721URIStorageUpgradeable,
    ERC721PausableUpgradeable,
    OwnableUpgradeable,
    ERC721BurnableUpgradeable,
    UUPSUpgradeable,
    UniversalNFTCore // Add UniversalNFTCore for universal features
{
    uint256 private _nextTokenId; // Track next token ID for minting
 
    /// @custom:oz-upgrades-unsafe-allow constructor
    constructor() {
        _disableInitializers();
    }
 
    function initialize(
        address initialOwner,
        string memory name,
        string memory symbol,
        address payable gatewayAddress, // Include EVM gateway address
        uint256 gas // Set gas limit for universal NFT calls
    ) public initializer {
        __ERC721_init(name, symbol);
        __ERC721Enumerable_init();
        __ERC721URIStorage_init();
        __ERC721Pausable_init();
        __Ownable_init(initialOwner);
        __ERC721Burnable_init();
        __UUPSUpgradeable_init();
        __UniversalNFTCore_init(gatewayAddress, address(this), gas); // Initialize universal NFT core
    }
 
    function safeMint(
        address to,
        string memory uri
    ) public onlyOwner whenNotPaused {
        // Generate globally unique token ID, feel free to supply your own logic
        uint256 hash = uint256(
            keccak256(
                abi.encodePacked(address(this), block.number, _nextTokenId++)
            )
        );
 
        uint256 tokenId = hash & 0x00FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF;
 
        _safeMint(to, tokenId);
        _setTokenURI(tokenId, uri);
    }
 
    function pause() public onlyOwner {
        _pause();
    }
 
    function unpause() public onlyOwner {
        _unpause();
    }
 
    function _authorizeUpgrade(
        address newImplementation
    ) internal override onlyOwner {}
 
    // The following functions are overrides required by Solidity.
 
    function _update(
        address to,
        uint256 tokenId,
        address auth
    )
        internal
        override(
            ERC721Upgradeable,
            ERC721EnumerableUpgradeable,
            ERC721PausableUpgradeable
        )
        returns (address)
    {
        return super._update(to, tokenId, auth);
    }
 
    function _increaseBalance(
        address account,
        uint128 value
    ) internal override(ERC721Upgradeable, ERC721EnumerableUpgradeable) {
        super._increaseBalance(account, value);
    }
 
    function tokenURI(
        uint256 tokenId
    )
        public
        view
        override(
            ERC721Upgradeable,
            ERC721URIStorageUpgradeable,
            UniversalNFTCore // Include UniversalNFTCore for URI overrides
        )
        returns (string memory)
    {
        return super.tokenURI(tokenId);
    }
 
    function supportsInterface(
        bytes4 interfaceId
    )
        public
        view
        override(
            ERC721Upgradeable,
            ERC721EnumerableUpgradeable,
            ERC721URIStorageUpgradeable,
            UniversalNFTCore // Include UniversalNFTCore for interface overrides
        )
        returns (bool)
    {
        return super.supportsInterface(interfaceId);
    }
}
  1. Deploy Universal NFT on ZetaChain. This is a required step, because a contract on ZetaChain handles all cross-chain token transfers, even between EVM chains.
  2. Deploy Universal NFT on a connected EVM chain (for example, Ethereum, Base, Polygon or BNB)
  3. Run ZetaChainUniversalNFT.setConnected(zrc20, contractAddress), where zrc20 is the ZRC-20 contract of the gas token of a connected EVM chain, this acts as an identifier for a chain, and contractAddress is the address of a Universal NFT on a connected EVM chain (see step 2).
  4. Run EVMUniversalNFT.setUniversal(contractAddress), where contractAddress is an address of a Universal NFT on ZetaChain (see step 1).

You now have two NFT contracts on ZetaChain and on an EVM chain connected. To add deploy an NFT contract on another EVM chain, repeat steps 2 and 3.

setConnected and setUniversal steps are required to establish a trusted connection between Universal NFT contracts on different chains. When accepting a cross-chain transfer a contract first checks that the transfer is coming from a trusted contract. On ZetaChain setting connected contract addresses helps the contract to route cross-chain transfers.

EVM → ZetaChain: no cross-chain fee is applied.

ZetaChain → EVM: a cross-chain fee is paid in ZETA. The amount depends on the ZRC-20 withdraw fee for the destination chain. ZETA is swapped for the destination chain's gas token ZRC-20.

EVM → EVM: a cross-chain fee is paid in the gas token of the source chain. The amount depends on the ZRC-20 withdraw fee for the destination chain. For example, if an NFT is transferred from Ethereum to BNB chain, the cross-chain fee is paid in ETH. On ZetaChain ZRC-20 ETH is swapped for ZRC-20 BNB, which is used to cover a call to the BNB chain.

EVM → EVM: if a transfer between two EVM chains reverts, the NFT will be transferred to the original sender on ZetaChain. This is prevent potential high gas fees associated with returning the NFT back to chain from which it was transferred in the first place. After the revert, the original sender can transfer the NFT from ZetaChain to any other chain.

https://github.com/zeta-chain/standard-contracts/tree/main/contracts/nft (opens in a new tab)