diff --git a/src/pages/developers/_meta.json b/src/pages/developers/_meta.json
index e8e3e364..6f034871 100644
--- a/src/pages/developers/_meta.json
+++ b/src/pages/developers/_meta.json
@@ -11,6 +11,10 @@
"title": "Universal EVM",
"description": "EVM enhanced with omnichain interoperability features, enabling the development of robust universal apps."
},
+ "standards": {
+ "title": "Contract Standards",
+ "description": "Learn about the different contract standards available on ZetaChain and how to use them."
+ },
"chains": {
"title": "Connected Chains",
"description": "Use Gateway to make calls to and from universal apps, deposit and withdraw tokens."
diff --git a/src/pages/developers/standards/_meta.json b/src/pages/developers/standards/_meta.json
new file mode 100644
index 00000000..f2f7e3b2
--- /dev/null
+++ b/src/pages/developers/standards/_meta.json
@@ -0,0 +1,10 @@
+{
+ "nft": {
+ "title": "Universal NFT",
+ "description": "The Universal NFT standard enables non-fungible tokens (ERC-721 NFT) to be minted on any chain and seamlessly transferred between connected chains."
+ },
+ "token": {
+ "title": "Universal Token",
+ "description": "The Universal Token standard enables ERC-20 fungible tokens to be minted on any chain and seamlessly transferred between connected chains."
+ }
+}
\ No newline at end of file
diff --git a/src/pages/developers/standards/nft.mdx b/src/pages/developers/standards/nft.mdx
new file mode 100644
index 00000000..9c90e545
--- /dev/null
+++ b/src/pages/developers/standards/nft.mdx
@@ -0,0 +1,673 @@
+import { Alert } from "~/components/shared";
+
+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: **Connected** and **Universal**.
+
+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.
+
+Universal NFT can be imported from npm:
+
+```solidity filename="contracts/Connected.sol"
+// SPDX-License-Identifier: MIT
+pragma solidity 0.8.26;
+
+import "@zetachain/standard-contracts/nft/contracts/evm/UniversalNFT.sol";
+
+contract Connected is UniversalNFT {
+ constructor(
+ address payable gatewayAddress,
+ address owner,
+ string memory name,
+ string memory symbol,
+ uint256 gasLimit
+ )
+ UniversalNFT(gatewayAddress, gasLimit)
+ Ownable(owner)
+ ERC721(name, symbol)
+ {}
+}
+```
+
+```solidity filename="contracts/Universal.sol"
+// SPDX-License-Identifier: MIT
+pragma solidity 0.8.26;
+
+import "@zetachain/standard-contracts/nft/contracts/zetachain/UniversalNFT.sol";
+
+contract Universal is UniversalNFT {
+ constructor(
+ address payable gatewayAddress,
+ address owner,
+ string memory name,
+ string memory symbol,
+ uint256 gasLimit,
+ address uniswapRouter
+ )
+ UniversalNFT(gatewayAddress, gasLimit, uniswapRouter)
+ Ownable(owner)
+ ERC721(name, symbol)
+ {}
+}
+```
+
+The Universal contract constructor accepts a Uniswap router address. It is
+recommended to use [the official Uniswap v2 router
+address](/reference/network/contracts), however, any Uniswap v2-compatible
+router should work. Uniswap is used to swap between ZRC-20 versions of gas
+tokens to cover outgoing (ZetaChain to connected chain) calls.
+
+## Setting Universal and Connected Addresses
+
+After deploying a Connected contract but before making cross-chain transfers,
+you must set the Universal contract address.
+
+```
+npx hardhat connected-set-universal --contract "$CONTRACT_ETHEREUM" --universal "$CONTRACT_ZETACHAIN" --network sepolia_testnet
+```
+
+You also need to set the mapping between a Connected contract address and a
+ZRC-20 address of the gas token of the connected chain.
+
+```
+npx hardhat universal-set-connected --contract "$CONTRACT_ZETACHAIN" --connected "$CONTRACT_ETHEREUM" --zrc20 "$ZRC20_ETHEREUM" --network zeta_testnet
+```
+
+Setting both addresses establishes a trusted connection between a Connected
+contract and a Universal contract.
+
+## Mint an NFT
+
+Mint an NFT on ZetaChain:
+
+```
+npx hardhat mint --network zeta_testnet --contract "$CONTRACT_ZETACHAIN" --token-uri https://example.com/nft/metadata/1
+```
+
+```
+{"contractAddress":"0x83E60C63b38974Cfe89c287f2b9ddF153eBD13A1","mintTransactionHash":"0xf2544827e5ac4434a300bca190f6b5e391e3cd7c87e515ac2dee4f7cb3b14e44","recipient":"0x4955a3F38ff86ae92A914445099caa8eA2B9bA32","tokenURI":"https://example.com/nft/metadata/1","tokenId":"1145931601449361378070955209842677114337257109052"}
+```
+
+## Cross-Chain NFT Transfer
+
+```
+npx hardhat transfer --network zeta_testnet --json --token-id "$TOKEN_ID" --from "$CONTRACT_ZETACHAIN" --to "$ZRC20_ETHEREUM"
+```
+
+## Implementation Details
+
+
+
+
+
+
+ {" "}
+ Information below is only relevant if you're interested in how universal NFTs work under the hood. You don't need to copy
+ or edit these contracts directly, they are imported from npm by your universal NFT contract.{" "}
+
+
+![https://excalidraw.com/#json=dQJisu_uJ0N8T6IPi2m0E,PJU63ktFfbi1WsfAXsompA](/img/docs/tutorials-nft.png)
+
+### Universal App Contract
+
+```solidity filename="evm/UniversalNFT.sol"
+// SPDX-License-Identifier: MIT
+pragma solidity 0.8.26;
+
+import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
+import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Enumerable.sol";
+import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
+import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Burnable.sol";
+import "@openzeppelin/contracts/access/Ownable2Step.sol";
+import "@zetachain/protocol-contracts/contracts/evm/GatewayEVM.sol";
+import {RevertContext} from "@zetachain/protocol-contracts/contracts/Revert.sol";
+import "../shared/Events.sol";
+
+abstract contract UniversalNFT is
+ ERC721,
+ ERC721Enumerable,
+ ERC721URIStorage,
+ Ownable2Step,
+ Events
+{
+ GatewayEVM public immutable gateway;
+ uint256 private _nextTokenId;
+ address public universal;
+ uint256 public immutable gasLimitAmount;
+
+ error InvalidAddress();
+ error Unauthorized();
+ error InvalidGasLimit();
+ error GasTokenTransferFailed();
+
+ function setUniversal(address contractAddress) external onlyOwner {
+ if (contractAddress == address(0)) revert InvalidAddress();
+ universal = contractAddress;
+ emit SetUniversal(contractAddress);
+ }
+
+ modifier onlyGateway() {
+ if (msg.sender != address(gateway)) revert Unauthorized();
+ _;
+ }
+
+ constructor(address payable gatewayAddress, uint256 gas) {
+ if (gatewayAddress == address(0)) revert InvalidAddress();
+ if (gas == 0) revert InvalidGasLimit();
+ gasLimitAmount = gas;
+ gateway = GatewayEVM(gatewayAddress);
+ }
+
+ function safeMint(address to, string memory uri) public onlyOwner {
+ uint256 hash = uint256(
+ keccak256(
+ abi.encodePacked(address(this), block.number, _nextTokenId++)
+ )
+ );
+
+ uint256 tokenId = hash & 0x00FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF;
+
+ _safeMint(to, tokenId);
+ _setTokenURI(tokenId, uri);
+ emit TokenMinted(to, tokenId, uri);
+ }
+
+ function transferCrossChain(
+ uint256 tokenId,
+ address receiver,
+ address destination
+ ) external payable {
+ if (receiver == address(0)) revert InvalidAddress();
+
+ string memory uri = tokenURI(tokenId);
+ _burn(tokenId);
+ bytes memory message = abi.encode(
+ destination,
+ receiver,
+ tokenId,
+ uri,
+ msg.sender
+ );
+ if (destination == address(0)) {
+ gateway.call(
+ universal,
+ message,
+ RevertOptions(address(this), false, address(0), message, 0)
+ );
+ } else {
+ gateway.depositAndCall{value: msg.value}(
+ universal,
+ message,
+ RevertOptions(
+ address(this),
+ true,
+ address(0),
+ abi.encode(receiver, tokenId, uri, msg.sender),
+ gasLimitAmount
+ )
+ );
+ }
+
+ emit TokenTransfer(destination, receiver, tokenId, uri);
+ }
+
+ function onCall(
+ MessageContext calldata context,
+ bytes calldata message
+ ) external payable onlyGateway returns (bytes4) {
+ if (context.sender != universal) revert Unauthorized();
+
+ (
+ address receiver,
+ uint256 tokenId,
+ string memory uri,
+ uint256 gasAmount,
+ address sender
+ ) = abi.decode(message, (address, uint256, string, uint256, address));
+
+ _safeMint(receiver, tokenId);
+ _setTokenURI(tokenId, uri);
+ if (gasAmount > 0) {
+ if (sender == address(0)) revert InvalidAddress();
+ (bool success, ) = payable(sender).call{value: gasAmount}("");
+ if (!success) revert GasTokenTransferFailed();
+ }
+ emit TokenTransferReceived(receiver, tokenId, uri);
+ return "";
+ }
+
+ function onRevert(RevertContext calldata context) external onlyGateway {
+ (, uint256 tokenId, string memory uri, address sender) = abi.decode(
+ context.revertMessage,
+ (address, uint256, string, address)
+ );
+
+ _safeMint(sender, tokenId);
+ _setTokenURI(tokenId, uri);
+ emit TokenTransferReverted(sender, tokenId, uri);
+ }
+
+ receive() external payable {}
+
+ fallback() external payable {}
+
+ // The following functions are overrides required by Solidity.
+
+ function _update(
+ address to,
+ uint256 tokenId,
+ address auth
+ ) internal override(ERC721, ERC721Enumerable) returns (address) {
+ return super._update(to, tokenId, auth);
+ }
+
+ function _increaseBalance(
+ address account,
+ uint128 value
+ ) internal override(ERC721, ERC721Enumerable) {
+ super._increaseBalance(account, value);
+ }
+
+ function tokenURI(
+ uint256 tokenId
+ ) public view override(ERC721, ERC721URIStorage) returns (string memory) {
+ return super.tokenURI(tokenId);
+ }
+
+ function supportsInterface(
+ bytes4 interfaceId
+ )
+ public
+ view
+ override(ERC721, ERC721Enumerable, ERC721URIStorage)
+ returns (bool)
+ {
+ return super.supportsInterface(interfaceId);
+ }
+}
+```
+
+#### Minting an NFT
+
+`safeMint` mint a new NFT. Token ID is generated from the contract address,
+block time, and an incrementing integer. This ensures that the token ID is
+unique across all chains. The only scenario where ID collision is possible is
+when two contracts are deployed on the same address on two different chains, and
+an NFT is minted on both exactly at the same time using the same integer. You
+can supply your own logic for generating token IDs.
+
+#### Transfer NFT from ZetaChain to a Connected Chain
+
+`transferCrossChain` transfers an NFT from ZetaChain to a connected chain.
+Transferring an NFT to a connected chain creates a transaction on a connected
+chain, which costs gas. To pay for gas, the NFT sender must provide the
+universal app with tokens for gas in the form of ZRC-20 version of the gas token
+of the connected chain. For example, when a user transfers an NFT from ZetaChain
+to Ethereum, they need to allow the contract to spend a certain amount of ZRC-20
+ETH.
+
+The function transfers the ZRC-20 tokens to the gateway contract.
+
+Next, the function encodes the token ID, sender's address and the URI into a
+message.
+
+`callOptions` are defined with the gas limit on the destination chain and
+`isArbitraryCall` (the second parameter) is set to false. `isArbitraryCall`
+determines if the call is arbitrary (a call to any contract on the destination
+chain, but without providing address of the universal contract making the call)
+or authenticated (the call is made to `onCall` function, the universal contract
+address is passed as a parameter). Setting `isArbitraryCall` to false is
+important, because the contract on a connected chain must know which universal
+contract is making a call to prevent unauthorized calls.
+
+`revertOptions` contain the address of a contract on ZetaChain, which will be
+called if the contract on the destination chain reverts (in our example we want
+to call the same universal contract), as well as the message that will be passed
+to the `onRevert` function of the revert contract. Pass the same encoded message
+to ensure that the universal contract can successfully mint the token back to
+the sender if the transfer fails.
+
+Finally, `gateway.call` is executed to initiate a cross-chain transfer of an NFT
+from ZetaChain to a connected chain. The destination chain is determined by the
+ZRC-20 contract address, which corresponds to the gas token of the connected
+chain. For example, to transfer the NFT to Ethereum, pass address of ZRC-20 ETH.
+
+#### Handling NFT Transfers from Connected Chains
+
+`onCall` is executed when an NFT transfer is received from a connected chain.
+
+First, `onCall` checks that the transfer originates from a trusted counterparty
+contract. This prevents unauthorized minting by malicious contracts.
+
+Since all cross-chain transfers are processed by ZetaChain, there are two
+scenarios when `onCall` is executed: when the destination is ZetaChain or when
+the destination is another connected chain.
+
+If the destination is a zero address, then the destination chain is ZetaChain.
+An NFT is minted and transferred to the recipient.
+
+If the destination is a non-zero address, the destination chain is another
+connected chain identified by the ZRC-20 gas token address in the destination
+field of the message. The contract initiates a transfer to the connected chain.
+First, it queries the withdraw fee on the destination chain. Then, it swaps the
+incoming ZRC-20 tokens into the ZRC-20 gas token of the destination chain. The
+swap uses the built-in Uniswap v2 pools, but any other swap mechanism can be
+used, instead. Finally, `gateway.call` is executed to initiate the transfer to
+the destination chain.
+
+#### Revert Handling
+
+If an NFT transfer from ZetaChain to a connected chain fails, `onRevert` is
+called. `onRevert` mints the NFT and transfers it back to original sender.
+
+### Connected Chain Contract
+
+```solidity filename="zetachain/UniversalNFT.sol"
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.26;
+
+import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
+import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Enumerable.sol";
+import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
+import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Burnable.sol";
+import "@openzeppelin/contracts/access/Ownable2Step.sol";
+import {RevertContext, RevertOptions} from "@zetachain/protocol-contracts/contracts/Revert.sol";
+import "@zetachain/protocol-contracts/contracts/zevm/interfaces/UniversalContract.sol";
+import "@zetachain/protocol-contracts/contracts/zevm/interfaces/IGatewayZEVM.sol";
+import "@zetachain/protocol-contracts/contracts/zevm/GatewayZEVM.sol";
+import {SwapHelperLib} from "@zetachain/toolkit/contracts/SwapHelperLib.sol";
+import {SystemContract} from "@zetachain/toolkit/contracts/SystemContract.sol";
+import "../shared/Events.sol";
+
+abstract contract UniversalNFT is
+ ERC721,
+ ERC721Enumerable,
+ ERC721URIStorage,
+ Ownable2Step,
+ UniversalContract,
+ Events
+{
+ GatewayZEVM public immutable gateway;
+ address public immutable uniswapRouter;
+ uint256 private _nextTokenId;
+ bool public constant isUniversal = true;
+ uint256 public immutable gasLimitAmount;
+
+ error TransferFailed();
+ error Unauthorized();
+ error InvalidAddress();
+ error InvalidGasLimit();
+ error ApproveFailed();
+
+ mapping(address => address) public connected;
+
+ modifier onlyGateway() {
+ if (msg.sender != address(gateway)) revert Unauthorized();
+ _;
+ }
+
+ constructor(
+ address payable gatewayAddress,
+ uint256 gas,
+ address uniswapRouterAddress
+ ) {
+ if (gatewayAddress == address(0) || uniswapRouterAddress == address(0))
+ revert InvalidAddress();
+ if (gas == 0) revert InvalidGasLimit();
+ gateway = GatewayZEVM(gatewayAddress);
+ uniswapRouter = uniswapRouterAddress;
+ gasLimitAmount = gas;
+ }
+
+ function setConnected(
+ address zrc20,
+ address contractAddress
+ ) external onlyOwner {
+ connected[zrc20] = contractAddress;
+ emit SetConnected(zrc20, contractAddress);
+ }
+
+ function transferCrossChain(
+ uint256 tokenId,
+ address receiver,
+ address destination
+ ) public {
+ if (receiver == address(0)) revert InvalidAddress();
+ string memory uri = tokenURI(tokenId);
+ _burn(tokenId);
+
+ (address gasZRC20, uint256 gasFee) = IZRC20(destination)
+ .withdrawGasFeeWithGasLimit(gasLimitAmount);
+ if (destination != gasZRC20) revert InvalidAddress();
+ if (
+ !IZRC20(destination).transferFrom(msg.sender, address(this), gasFee)
+ ) revert TransferFailed();
+ if (!IZRC20(destination).approve(address(gateway), gasFee)) {
+ revert ApproveFailed();
+ }
+ bytes memory message = abi.encode(
+ receiver,
+ tokenId,
+ uri,
+ 0,
+ msg.sender
+ );
+
+ CallOptions memory callOptions = CallOptions(gasLimitAmount, false);
+
+ RevertOptions memory revertOptions = RevertOptions(
+ address(this),
+ true,
+ address(0),
+ abi.encode(tokenId, uri, msg.sender),
+ gasLimitAmount
+ );
+
+ gateway.call(
+ abi.encodePacked(connected[destination]),
+ destination,
+ message,
+ callOptions,
+ revertOptions
+ );
+
+ emit TokenTransfer(receiver, destination, tokenId, uri);
+ }
+
+ function safeMint(address to, string memory uri) public onlyOwner {
+ uint256 hash = uint256(
+ keccak256(
+ abi.encodePacked(address(this), block.number, _nextTokenId++)
+ )
+ );
+
+ uint256 tokenId = hash & 0x00FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF;
+
+ _safeMint(to, tokenId);
+ _setTokenURI(tokenId, uri);
+ }
+
+ function onCall(
+ MessageContext calldata context,
+ address zrc20,
+ uint256 amount,
+ bytes calldata message
+ ) external override onlyGateway {
+ if (context.sender != connected[zrc20]) revert Unauthorized();
+
+ (
+ address destination,
+ address receiver,
+ uint256 tokenId,
+ string memory uri,
+ address sender
+ ) = abi.decode(message, (address, address, uint256, string, address));
+
+ if (destination == address(0)) {
+ _safeMint(receiver, tokenId);
+ _setTokenURI(tokenId, uri);
+ emit TokenTransferReceived(receiver, tokenId, uri);
+ } else {
+ (address gasZRC20, uint256 gasFee) = IZRC20(destination)
+ .withdrawGasFeeWithGasLimit(gasLimitAmount);
+ if (destination != gasZRC20) revert InvalidAddress();
+
+ uint256 out = SwapHelperLib.swapExactTokensForTokens(
+ uniswapRouter,
+ zrc20,
+ amount,
+ destination,
+ 0
+ );
+
+ if (!IZRC20(destination).approve(address(gateway), out)) {
+ revert ApproveFailed();
+ }
+ gateway.withdrawAndCall(
+ abi.encodePacked(connected[destination]),
+ out - gasFee,
+ destination,
+ abi.encode(receiver, tokenId, uri, out - gasFee, sender),
+ CallOptions(gasLimitAmount, false),
+ RevertOptions(
+ address(this),
+ true,
+ address(0),
+ abi.encode(tokenId, uri, sender),
+ 0
+ )
+ );
+ }
+ emit TokenTransferToDestination(receiver, destination, tokenId, uri);
+ }
+
+ function onRevert(RevertContext calldata context) external onlyGateway {
+ (uint256 tokenId, string memory uri, address sender) = abi.decode(
+ context.revertMessage,
+ (uint256, string, address)
+ );
+
+ _safeMint(sender, tokenId);
+ _setTokenURI(tokenId, uri);
+ emit TokenTransferReverted(sender, tokenId, uri);
+ }
+
+ // The following functions are overrides required by Solidity.
+
+ function _update(
+ address to,
+ uint256 tokenId,
+ address auth
+ ) internal override(ERC721, ERC721Enumerable) returns (address) {
+ return super._update(to, tokenId, auth);
+ }
+
+ function _increaseBalance(
+ address account,
+ uint128 value
+ ) internal override(ERC721, ERC721Enumerable) {
+ super._increaseBalance(account, value);
+ }
+
+ function tokenURI(
+ uint256 tokenId
+ ) public view override(ERC721, ERC721URIStorage) returns (string memory) {
+ return super.tokenURI(tokenId);
+ }
+
+ function supportsInterface(
+ bytes4 interfaceId
+ )
+ public
+ view
+ override(ERC721, ERC721Enumerable, ERC721URIStorage)
+ returns (bool)
+ {
+ return super.supportsInterface(interfaceId);
+ }
+}
+```
+
+#### Transfer NFT from a Connected Chain
+
+`transferCrossChain` initiates a transfer of an NFT to ZetaChain or to another
+connected chain.
+
+To transfer an NFT to ZetaChain the destination address must be specified as a
+zero address.
+
+To transfer an NFT to another connected chain the destination address must be
+the ZRC-20 address of the gas token of the destination chain. For example, to
+transfer to Ethereum, the destination is ZRC-20 ETH address.
+
+When transferring to ZetaChain a no asset `gateway.call` is executed, because
+cross-chain calls to ZetaChain do not require the sender to cover gas costs.
+
+When transferring to another connected chain, `gateway.depositAndCall` is
+executed with some gas tokens to cover the gas costs on the destination chain.
+
+#### Handling NFT Transfers
+
+`onCall` is executed when an NFT transfer is received from a connected chain or
+ZetaChain.
+
+Since all cross-chain transactions go through a universal contract on ZetaChain,
+`onCall` checks that the call is made from a trusted counterparty universal
+contract address to prevent unauthorized minting.
+
+#### Revert Handling
+
+If an NFT transfer from ZetaChain to a connected chain fails, `onRevert` is
+called. `onRevert` mints the NFT and transfers it back to original sender.
diff --git a/src/pages/developers/standards/token.mdx b/src/pages/developers/standards/token.mdx
new file mode 100644
index 00000000..2f951bf5
--- /dev/null
+++ b/src/pages/developers/standards/token.mdx
@@ -0,0 +1,52 @@
+The Universal Token standard enables ERC-20 fungible tokens 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 amount is sent in a message to the token contract on the destination
+chain, where a corresponding token is minted.
+
+The Universal Token standard works the same way as [Universal
+NFT](/developers/standards/nft).
+
+```solidity filename="contracts/Connected.sol"
+// SPDX-License-Identifier: MIT
+pragma solidity 0.8.26;
+
+import "@zetachain/standard-contracts/token/contracts/evm/UniversalNFT.sol";
+
+contract Connected is UniversalToken {
+ constructor(
+ address payable gatewayAddress,
+ address owner,
+ string memory name,
+ string memory symbol,
+ uint256 gasLimit
+ )
+ UniversalToken(gatewayAddress, gasLimit)
+ Ownable(owner)
+ ERC20(name, symbol)
+ {}
+}
+```
+
+```solidity filename="contracts/Universal.sol"
+// SPDX-License-Identifier: MIT
+pragma solidity 0.8.26;
+
+import "@zetachain/standard-contracts/token/contracts/zetachain/UniversalNFT.sol";
+
+contract Universal is UniversalToken {
+ constructor(
+ address payable gatewayAddress,
+ address owner,
+ string memory name,
+ string memory symbol,
+ uint256 gasLimit,
+ address uniswapRouter
+ )
+ UniversalToken(gatewayAddress, gasLimit, uniswapRouter)
+ Ownable(owner)
+ ERC20(name, symbol)
+ {}
+}
+```