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.
Using ThirdWeb
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.
Installing from NPM
yarn add @zetachain/standard-contracts@v1.0.0-rc2
Starting from Scratch
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.
Starting from an OpenZeppelin Template
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);
}
}
Deployment
- 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.
- Deploy Universal NFT on a connected EVM chain (for example, Ethereum, Base, Polygon or BNB)
- 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, andcontractAddress
is the address of a Universal NFT on a connected EVM chain (see step 2). - Run
EVMUniversalNFT.setUniversal(contractAddress)
, wherecontractAddress
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.
Gas Fees
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.
Revert Handling
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.
Source Code
https://github.com/zeta-chain/standard-contracts/tree/main/contracts/nft (opens in a new tab)