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) + {} +} +```