From 9b99ad6b1b4df342dbcc5f9eef4f113519ab61d8 Mon Sep 17 00:00:00 2001 From: Denis Fadeev Date: Tue, 7 Jan 2025 13:02:06 +0500 Subject: [PATCH] refactor: extracted core logic into an abstract `UniversalNFTCore` contract (#36) --- contracts/nft/contracts/evm/UniversalNFT.sol | 158 ++------- .../nft/contracts/evm/UniversalNFTCore.sol | 229 +++++++++++++ .../nft/contracts/zetachain/UniversalNFT.sol | 217 ++----------- .../contracts/zetachain/UniversalNFTCore.sol | 302 ++++++++++++++++++ contracts/nft/scripts/localnet.sh | 6 +- contracts/nft/tasks/transfer.ts | 18 +- .../token/contracts/evm/UniversalToken.sol | 122 +------ .../contracts/evm/UniversalTokenCore.sol | 188 +++++++++++ .../contracts/zetachain/UniversalToken.sol | 183 +---------- .../zetachain/UniversalTokenCore.sol | 253 +++++++++++++++ 10 files changed, 1066 insertions(+), 610 deletions(-) create mode 100644 contracts/nft/contracts/evm/UniversalNFTCore.sol create mode 100644 contracts/nft/contracts/zetachain/UniversalNFTCore.sol create mode 100644 contracts/token/contracts/evm/UniversalTokenCore.sol create mode 100644 contracts/token/contracts/zetachain/UniversalTokenCore.sol diff --git a/contracts/nft/contracts/evm/UniversalNFT.sol b/contracts/nft/contracts/evm/UniversalNFT.sol index 9fa8868..aac29e7 100644 --- a/contracts/nft/contracts/evm/UniversalNFT.sol +++ b/contracts/nft/contracts/evm/UniversalNFT.sol @@ -1,45 +1,30 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.26; +pragma solidity ^0.8.26; -import "@openzeppelin/contracts/token/ERC721/ERC721.sol"; -import "@zetachain/protocol-contracts/contracts/evm/GatewayEVM.sol"; -import {RevertContext} from "@zetachain/protocol-contracts/contracts/Revert.sol"; -import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; 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 {ERC721BurnableUpgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC721/extensions/ERC721BurnableUpgradeable.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 {ERC721PausableUpgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC721/extensions/ERC721PausableUpgradeable.sol"; -import "../shared/UniversalNFTEvents.sol"; +// Import UniversalNFTCore for universal NFT functionality +import "./UniversalNFTCore.sol"; contract UniversalNFT is Initializable, ERC721Upgradeable, - ERC721URIStorageUpgradeable, ERC721EnumerableUpgradeable, + ERC721URIStorageUpgradeable, ERC721PausableUpgradeable, OwnableUpgradeable, ERC721BurnableUpgradeable, UUPSUpgradeable, - UniversalNFTEvents + UniversalNFTCore // Add UniversalNFTCore for universal features { - GatewayEVM public gateway; - uint256 private _nextTokenId; - address public universal; - uint256 public gasLimitAmount; - - error InvalidAddress(); - error Unauthorized(); - error InvalidGasLimit(); - error GasTokenTransferFailed(); - - modifier onlyGateway() { - if (msg.sender != address(gateway)) revert Unauthorized(); - _; - } + uint256 private _nextTokenId; // Track next token ID for minting /// @custom:oz-upgrades-unsafe-allow constructor constructor() { @@ -50,36 +35,24 @@ contract UniversalNFT is address initialOwner, string memory name, string memory symbol, - address payable gatewayAddress, - uint256 gas + address payable gatewayAddress, // Include EVM gateway address + uint256 gas // Set gas limit for universal NFT transfers ) public initializer { __ERC721_init(name, symbol); __ERC721Enumerable_init(); __ERC721URIStorage_init(); + __ERC721Pausable_init(); __Ownable_init(initialOwner); __ERC721Burnable_init(); __UUPSUpgradeable_init(); - if (gatewayAddress == address(0)) revert InvalidAddress(); - if (gas == 0) revert InvalidGasLimit(); - gasLimitAmount = gas; - gateway = GatewayEVM(gatewayAddress); - } - - function setGasLimit(uint256 gas) external onlyOwner { - if (gas == 0) revert InvalidGasLimit(); - gasLimitAmount = gas; - } - - function setUniversal(address contractAddress) external onlyOwner { - if (contractAddress == address(0)) revert InvalidAddress(); - universal = contractAddress; - emit SetUniversal(contractAddress); + __UniversalNFTCore_init(gatewayAddress, address(this), gas); // Initialize universal NFT core } function safeMint( address to, string memory uri - ) public whenNotPaused onlyOwner { + ) 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++) @@ -90,83 +63,19 @@ contract UniversalNFT is _safeMint(to, tokenId); _setTokenURI(tokenId, uri); - emit TokenMinted(to, tokenId, uri); } - function transferCrossChain( - uint256 tokenId, - address receiver, - address destination - ) external payable whenNotPaused { - 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 pause() public onlyOwner { + _pause(); } - 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 unpause() public onlyOwner { + _unpause(); } - 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); - } + function _authorizeUpgrade( + address newImplementation + ) internal override onlyOwner {} // The following functions are overrides required by Solidity. @@ -198,7 +107,11 @@ contract UniversalNFT is ) public view - override(ERC721Upgradeable, ERC721URIStorageUpgradeable) + override( + ERC721Upgradeable, + ERC721URIStorageUpgradeable, + UniversalNFTCore // Include UniversalNFTCore for URI overrides + ) returns (string memory) { return super.tokenURI(tokenId); @@ -212,24 +125,11 @@ contract UniversalNFT is override( ERC721Upgradeable, ERC721EnumerableUpgradeable, - ERC721URIStorageUpgradeable + 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 {} } diff --git a/contracts/nft/contracts/evm/UniversalNFTCore.sol b/contracts/nft/contracts/evm/UniversalNFTCore.sol new file mode 100644 index 0000000..41c272e --- /dev/null +++ b/contracts/nft/contracts/evm/UniversalNFTCore.sol @@ -0,0 +1,229 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +import "@zetachain/protocol-contracts/contracts/evm/GatewayEVM.sol"; +import {RevertOptions} from "@zetachain/protocol-contracts/contracts/evm/GatewayEVM.sol"; +import "../shared/UniversalNFTEvents.sol"; +import {ERC721Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC721/ERC721Upgradeable.sol"; +import {ERC721URIStorageUpgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC721/extensions/ERC721URIStorageUpgradeable.sol"; +import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; + +/** + * @title UniversalNFTCore + * @dev This abstract contract provides the core logic for Universal NFTs. It is designed + * to be imported into an OpenZeppelin-based ERC721 implementation, extending its + * functionality with cross-chain NFT transfer capabilities via GatewayEVM. This + * contract facilitates cross-chain NFT transfers to and from EVM-based networks. + * It's important to set the universal contract address before making cross-chain transfers. + */ +abstract contract UniversalNFTCore is + ERC721Upgradeable, + ERC721URIStorageUpgradeable, + OwnableUpgradeable, + UniversalNFTEvents +{ + // Address of the EVM gateway contract + GatewayEVM public gateway; + + // The address of the Universal NFT contract on ZetaChain. This contract serves + // as a key component for handling all cross-chain transfers while also functioning + // as an ERC-721 Universal NFT. + address public universal; + + // The amount of gas used when making cross-chain transfers + uint256 public gasLimitAmount; + + error InvalidAddress(); + error Unauthorized(); + error InvalidGasLimit(); + error GasTokenTransferFailed(); + + modifier onlyGateway() { + if (msg.sender != address(gateway)) revert Unauthorized(); + _; + } + + /** + * @notice Sets the gas limit for cross-chain transfers. + * @dev Can only be called by the contract owner. + * @param gas New gas limit value. + */ + function setGasLimit(uint256 gas) external onlyOwner { + if (gas == 0) revert InvalidGasLimit(); + gasLimitAmount = gas; + } + + /** + * @notice Sets the universal contract address. + * @dev Can only be called by the contract owner. + * @param contractAddress The address of the universal contract. + */ + function setUniversal(address contractAddress) external onlyOwner { + if (contractAddress == address(0)) revert InvalidAddress(); + universal = contractAddress; + emit SetUniversal(contractAddress); + } + + /** + * @notice Sets the EVM gateway contract address. + * @dev Can only be called by the contract owner. + * @param gatewayAddress The address of the gateway contract. + */ + function setGateway(address gatewayAddress) external onlyOwner { + if (gatewayAddress == address(0)) revert InvalidAddress(); + gateway = GatewayEVM(gatewayAddress); + } + + /** + * @notice Initializes the contract with gateway, universal, and gas limit settings. + * @dev To be called during contract deployment. + * @param gatewayAddress The address of the gateway contract. + * @param universalAddress The address of the universal contract. + * @param gasLimit The gas limit to set. + */ + function __UniversalNFTCore_init( + address gatewayAddress, + address universalAddress, + uint256 gasLimit + ) internal { + if (gatewayAddress == address(0)) revert InvalidAddress(); + if (universalAddress == address(0)) revert InvalidAddress(); + if (gasLimit == 0) revert InvalidGasLimit(); + gateway = GatewayEVM(gatewayAddress); + universal = universalAddress; + gasLimitAmount = gasLimit; + } + + /** + * @notice Transfers an NFT to another chain. + * @dev Burns the NFT locally, then uses the Gateway to send a message to + * mint the same NFT on the destination chain. If the destination is the zero + * address, transfers the NFT to ZetaChain. + * @param tokenId The ID of the NFT to transfer. + * @param receiver The address on the destination chain that will receive the NFT. + * @param destination The ZRC-20 address of the gas token of the destination chain. + */ + function transferCrossChain( + uint256 tokenId, + address receiver, + address destination + ) external payable virtual { + if (receiver == address(0)) revert InvalidAddress(); + + string memory uri = tokenURI(tokenId); + + _burn(tokenId); + + bytes memory message = abi.encode( + destination, + receiver, + tokenId, + uri, + msg.sender + ); + + emit TokenTransfer(destination, receiver, tokenId, uri); + + 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 + ) + ); + } + } + + /** + * @notice Mint an NFT in response to an incoming cross-chain transfer. + * @dev Called by the Gateway upon receiving a message. + * @param context The message context. + * @param message The encoded message containing information about the NFT. + * @return A constant indicating the function was successfully handled. + */ + 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 ""; + } + + /** + * @notice Mint an NFT and send it back to the sender if a cross-chain transfer fails. + * @dev Called by the Gateway if a call fails. + * @param context The revert context containing metadata and revert message. + */ + 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); + } + + /** + * @notice Gets the token URI for a given token ID. + * @param tokenId The ID of the token. + * @return The token URI as a string. + */ + function tokenURI( + uint256 tokenId + ) + public + view + virtual + override(ERC721Upgradeable, ERC721URIStorageUpgradeable) + returns (string memory) + { + return super.tokenURI(tokenId); + } + + /** + * @notice Checks if the contract supports a specific interface. + * @param interfaceId The interface identifier to check. + * @return True if the interface is supported, false otherwise. + */ + function supportsInterface( + bytes4 interfaceId + ) + public + view + virtual + override(ERC721Upgradeable, ERC721URIStorageUpgradeable) + returns (bool) + { + return super.supportsInterface(interfaceId); + } +} diff --git a/contracts/nft/contracts/zetachain/UniversalNFT.sol b/contracts/nft/contracts/zetachain/UniversalNFT.sol index 7b3e142..b223faf 100644 --- a/contracts/nft/contracts/zetachain/UniversalNFT.sol +++ b/contracts/nft/contracts/zetachain/UniversalNFT.sol @@ -1,13 +1,6 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.26; -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/interfaces/IWZETA.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 {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"; @@ -17,39 +10,21 @@ import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Ini import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; -import "../shared/UniversalNFTEvents.sol"; +// Import UniversalNFTCore for universal NFT functionality +import "./UniversalNFTCore.sol"; contract UniversalNFT is - Initializable, - ERC721Upgradeable, - ERC721URIStorageUpgradeable, - ERC721EnumerableUpgradeable, - ERC721PausableUpgradeable, - OwnableUpgradeable, - ERC721BurnableUpgradeable, - UniversalContract, - UUPSUpgradeable, - UniversalNFTEvents + 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 { - GatewayZEVM public gateway; - address public uniswapRouter; - uint256 private _nextTokenId; - bool public constant isUniversal = true; - uint256 public gasLimitAmount; - - error TransferFailed(); - error Unauthorized(); - error InvalidAddress(); - error InvalidGasLimit(); - error ApproveFailed(); - error ZeroMsgValue(); - - mapping(address => address) public connected; - - modifier onlyGateway() { - if (msg.sender != address(gateway)) revert Unauthorized(); - _; - } + uint256 private _nextTokenId; // Track next token ID for minting /// @custom:oz-upgrades-unsafe-allow constructor constructor() { @@ -60,105 +35,25 @@ contract UniversalNFT is address initialOwner, string memory name, string memory symbol, - address payable gatewayAddress, - uint256 gas, - address uniswapRouterAddress + 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(); - if (gatewayAddress == address(0) || uniswapRouterAddress == address(0)) - revert InvalidAddress(); - if (gas == 0) revert InvalidGasLimit(); - gateway = GatewayZEVM(gatewayAddress); - uniswapRouter = uniswapRouterAddress; - gasLimitAmount = gas; - } - - function setGasLimit(uint256 gas) external onlyOwner { - if (gas == 0) revert InvalidGasLimit(); - 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 payable whenNotPaused { - if (msg.value == 0) revert ZeroMsgValue(); - 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(); - - address WZETA = gateway.zetaToken(); - - IWETH9(WZETA).deposit{value: msg.value}(); - IWETH9(WZETA).approve(uniswapRouter, msg.value); - - uint256 out = SwapHelperLib.swapTokensForExactTokens( - uniswapRouter, - WZETA, - gasFee, - gasZRC20, - msg.value - ); - - uint256 remaining = msg.value - out; - - if (remaining > 0) { - IWETH9(WZETA).withdraw(remaining); - (bool success, ) = msg.sender.call{value: remaining}(""); - if (!success) revert TransferFailed(); - } - - 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 - ); - - IZRC20(gasZRC20).approve(address(gateway), gasFee); - gateway.call( - abi.encodePacked(connected[destination]), - destination, - message, - callOptions, - revertOptions - ); - - emit TokenTransfer(receiver, destination, tokenId, uri); + __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++) @@ -171,71 +66,6 @@ contract UniversalNFT is _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( @@ -266,7 +96,11 @@ contract UniversalNFT is ) public view - override(ERC721Upgradeable, ERC721URIStorageUpgradeable) + override( + ERC721Upgradeable, + ERC721URIStorageUpgradeable, + UniversalNFTCore // Include UniversalNFTCore for URI overrides + ) returns (string memory) { return super.tokenURI(tokenId); @@ -280,7 +114,8 @@ contract UniversalNFT is override( ERC721Upgradeable, ERC721EnumerableUpgradeable, - ERC721URIStorageUpgradeable + ERC721URIStorageUpgradeable, + UniversalNFTCore // Include UniversalNFTCore for interface overrides ) returns (bool) { @@ -299,5 +134,5 @@ contract UniversalNFT is _unpause(); } - receive() external payable {} + receive() external payable {} // Receive ZETA to pay for gas } diff --git a/contracts/nft/contracts/zetachain/UniversalNFTCore.sol b/contracts/nft/contracts/zetachain/UniversalNFTCore.sol new file mode 100644 index 0000000..7c81233 --- /dev/null +++ b/contracts/nft/contracts/zetachain/UniversalNFTCore.sol @@ -0,0 +1,302 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +import "../shared/UniversalNFTEvents.sol"; +import {ERC721Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC721/ERC721Upgradeable.sol"; +import {ERC721URIStorageUpgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC721/extensions/ERC721URIStorageUpgradeable.sol"; +import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.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/interfaces/IWZETA.sol"; +import "@zetachain/protocol-contracts/contracts/zevm/GatewayZEVM.sol"; +import {SwapHelperLib} from "@zetachain/toolkit/contracts/SwapHelperLib.sol"; + +/** + * @title UniversalNFTCore + * @dev This abstract contract provides the core logic for Universal NFTs. It is designed + * to be imported into an OpenZeppelin-based ERC721 implementation, extending its + * functionality with cross-chain NFT transfer capabilities via GatewayZEVM. This + * contract facilitates cross-chain NFT transfers to and from ZetaChain and other + * connected EVM-based networks. + */ +abstract contract UniversalNFTCore is + UniversalContract, + ERC721Upgradeable, + ERC721URIStorageUpgradeable, + OwnableUpgradeable, + UniversalNFTEvents +{ + // Address of the ZetaChain Gateway contract + GatewayZEVM public gateway; + + // Address of the Uniswap Router for token swaps + address public uniswapRouter; + + // Indicates this contract implements a Universal Contract + bool public constant isUniversal = true; + + // Gas limit for cross-chain operations + uint256 public gasLimitAmount; + + // Mapping of connected ZRC-20 tokens to their respective contracts + mapping(address => address) public connected; + + error TransferFailed(); + error Unauthorized(); + error InvalidAddress(); + error InvalidGasLimit(); + error ApproveFailed(); + error ZeroMsgValue(); + + modifier onlyGateway() { + if (msg.sender != address(gateway)) revert Unauthorized(); + _; + } + + /** + * @notice Sets the ZetaChain gateway contract address. + * @dev Can only be called by the contract owner. + * @param gatewayAddress The address of the gateway contract. + */ + function setGateway(address gatewayAddress) external onlyOwner { + if (gatewayAddress == address(0)) revert InvalidAddress(); + gateway = GatewayZEVM(payable(gatewayAddress)); + } + + /** + * @notice Initializes the contract. + * @dev Should be called during contract deployment. + * @param gatewayAddress Address of the Gateway contract. + * @param gasLimit Gas limit for cross-chain calls. + * @param uniswapRouterAddress Address of the Uniswap router contract. + */ + function __UniversalNFTCore_init( + address gatewayAddress, + uint256 gasLimit, + address uniswapRouterAddress + ) internal { + if (gatewayAddress == address(0) || uniswapRouterAddress == address(0)) + revert InvalidAddress(); + if (gasLimit == 0) revert InvalidGasLimit(); + gateway = GatewayZEVM(payable(gatewayAddress)); + uniswapRouter = uniswapRouterAddress; + gasLimitAmount = gasLimit; + } + + /** + * @notice Sets the gas limit for cross-chain transfers. + * @dev Can only be called by the contract owner. + * @param gas New gas limit value. + */ + function setGasLimit(uint256 gas) external onlyOwner { + if (gas == 0) revert InvalidGasLimit(); + gasLimitAmount = gas; + } + + /** + * @notice Links a ZRC-20 gas token address to an NFT contract + * on the corresponding chain. + * @dev Can only be called by the contract owner. + * @param zrc20 Address of the ZRC-20 token. + * @param contractAddress Address of the corresponding contract. + */ + function setConnected( + address zrc20, + address contractAddress + ) external onlyOwner { + connected[zrc20] = contractAddress; + emit SetConnected(zrc20, contractAddress); + } + + /** + * @notice Transfers an NFT to a connected chain. + * @dev This function accepts native ZETA tokens as gas fees, which are swapped + * for the corresponding ZRC-20 gas token of the destination chain. The NFT is then + * transferred to the destination chain using the ZetaChain Gateway. + * @param tokenId The ID of the NFT to transfer. + * @param receiver Address of the recipient on the destination chain. + * @param destination Address of the ZRC-20 gas token for the destination chain. + */ + function transferCrossChain( + uint256 tokenId, + address receiver, + address destination + ) public payable { + if (msg.value == 0) revert ZeroMsgValue(); + if (receiver == address(0)) revert InvalidAddress(); + + string memory uri = tokenURI(tokenId); + _burn(tokenId); + + emit TokenTransfer(receiver, destination, tokenId, uri); + + (address gasZRC20, uint256 gasFee) = IZRC20(destination) + .withdrawGasFeeWithGasLimit(gasLimitAmount); + if (destination != gasZRC20) revert InvalidAddress(); + + address WZETA = gateway.zetaToken(); + IWETH9(WZETA).deposit{value: msg.value}(); + if (!IWETH9(WZETA).approve(uniswapRouter, msg.value)) { + revert ApproveFailed(); + } + + uint256 out = SwapHelperLib.swapTokensForExactTokens( + uniswapRouter, + WZETA, + gasFee, + gasZRC20, + msg.value + ); + + uint256 remaining = msg.value - out; + if (remaining > 0) { + IWETH9(WZETA).withdraw(remaining); + (bool success, ) = msg.sender.call{value: remaining}(""); + if (!success) revert TransferFailed(); + } + + 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 + ); + + if (!IZRC20(gasZRC20).approve(address(gateway), gasFee)) { + revert ApproveFailed(); + } + + gateway.call( + abi.encodePacked(connected[destination]), + destination, + message, + callOptions, + revertOptions + ); + } + + /** + * @notice Handles cross-chain NFT transfers. + * @dev This function is called by the Gateway contract upon receiving a message. + * If the destination is ZetaChain, mint an NFT and set its URI. + * If the destination is another chain, swap the gas token for the corresponding + * ZRC-20 token and use the Gateway to send a message to mint an NFT on the + * destination chain. + * @param context Message context metadata. + * @param zrc20 ZRC-20 token address. + * @param amount Amount of token provided. + * @param message Encoded payload containing NFT metadata. + */ + 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); + } + + /** + * @notice Handles a cross-chain call failure and reverts the NFT transfer. + * @param context Metadata about the failed call. + */ + 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); + } + + /** + * @notice Returns the metadata URI for an NFT. + * @param tokenId The ID of the token. + * @return The URI string. + */ + function tokenURI( + uint256 tokenId + ) + public + view + virtual + override(ERC721Upgradeable, ERC721URIStorageUpgradeable) + returns (string memory) + { + return super.tokenURI(tokenId); + } + + /** + * @notice Checks if the contract supports a specific interface. + * @param interfaceId The interface ID. + * @return True if the interface is supported, false otherwise. + */ + function supportsInterface( + bytes4 interfaceId + ) + public + view + virtual + override(ERC721Upgradeable, ERC721URIStorageUpgradeable) + returns (bool) + { + return super.supportsInterface(interfaceId); + } +} diff --git a/contracts/nft/scripts/localnet.sh b/contracts/nft/scripts/localnet.sh index 731b2c4..3411386 100755 --- a/contracts/nft/scripts/localnet.sh +++ b/contracts/nft/scripts/localnet.sh @@ -56,19 +56,19 @@ npx hardhat localnet-check balance echo -e "\nTransferring NFT: ZetaChain → Ethereum..." -npx hardhat nft:transfer --network localhost --json --token-id "$NFT_ID" --from "$CONTRACT_ZETACHAIN" --to "$ZRC20_ETHEREUM" --gas-amount 1 +npx hardhat nft:transfer --network localhost --json --token-id "$NFT_ID" --contract "$CONTRACT_ZETACHAIN" --destination "$ZRC20_ETHEREUM" --gas-amount 1 npx hardhat localnet-check balance echo -e "\nTransferring NFT: Ethereum → BNB..." -npx hardhat nft:transfer --network localhost --json --token-id "$NFT_ID" --from "$CONTRACT_ETHEREUM" --to "$ZRC20_BNB" --gas-amount 1 +npx hardhat nft:transfer --network localhost --json --token-id "$NFT_ID" --contract "$CONTRACT_ETHEREUM" --destination "$ZRC20_BNB" --gas-amount 1 npx hardhat localnet-check balance echo -e "\nTransferring NFT: BNB → ZetaChain..." -npx hardhat nft:transfer --network localhost --json --token-id "$NFT_ID" --from "$CONTRACT_BNB" +npx hardhat nft:transfer --network localhost --json --token-id "$NFT_ID" --contract "$CONTRACT_BNB" npx hardhat localnet-check balance diff --git a/contracts/nft/tasks/transfer.ts b/contracts/nft/tasks/transfer.ts index cf062da..c445b4d 100644 --- a/contracts/nft/tasks/transfer.ts +++ b/contracts/nft/tasks/transfer.ts @@ -7,14 +7,14 @@ const main = async (args: any, hre: HardhatRuntimeEnvironment) => { const { isAddress } = hre.ethers.utils; - if (!isAddress(args.to) || !isAddress(args.revertAddress)) { + if (!isAddress(args.destination) || !isAddress(args.revertAddress)) { throw new Error("Invalid Ethereum address provided."); } - const nftContract = await ethers.getContractAt("IERC721", args.from); + const nftContract = await ethers.getContractAt("IERC721", args.contract); const approveTx = await nftContract .connect(signer) - .approve(args.from, args.tokenId); + .approve(args.contract, args.tokenId); await approveTx.wait(); const txOptions = { @@ -24,7 +24,7 @@ const main = async (args: any, hre: HardhatRuntimeEnvironment) => { const contract = await ethers.getContractAt( "ZetaChainUniversalNFT", - args.from + args.contract ); const gasAmount = ethers.utils.parseUnits(args.gasAmount, 18); @@ -34,7 +34,7 @@ const main = async (args: any, hre: HardhatRuntimeEnvironment) => { const tx = await contract.transferCrossChain( args.tokenId, receiver, - args.to, + args.destination, { ...txOptions, value: gasAmount } ); @@ -42,7 +42,7 @@ const main = async (args: any, hre: HardhatRuntimeEnvironment) => { if (args.json) { console.log( JSON.stringify({ - contractAddress: args.from, + contractAddress: args.contract, transferTransactionHash: tx.hash, sender: signer.address, tokenId: args.tokenId, @@ -50,7 +50,7 @@ const main = async (args: any, hre: HardhatRuntimeEnvironment) => { ); } else { console.log(`🚀 Successfully transferred NFT to the contract. - 📜 Contract address: ${args.from} + 📜 Contract address: ${args.contract} 🖼 NFT Contract address: ${args.nftContract} 🆔 Token ID: ${args.tokenId} 🔗 Transaction hash: ${tx.hash}`); @@ -63,7 +63,7 @@ export const nftTransfer = task( main ) .addOptionalParam("receiver", "The address to receive the NFT") - .addParam("from", "The contract being transferred from") + .addParam("contract", "The contract being transferred from") .addParam("tokenId", "The ID of the NFT to transfer") .addOptionalParam( "txOptionsGasPrice", @@ -93,7 +93,7 @@ export const nftTransfer = task( .addFlag("isArbitraryCall", "Whether the call is arbitrary") .addFlag("json", "Output the result in JSON format") .addOptionalParam( - "to", + "destination", "ZRC-20 of the gas token of the destination chain", "0x0000000000000000000000000000000000000000" ) diff --git a/contracts/token/contracts/evm/UniversalToken.sol b/contracts/token/contracts/evm/UniversalToken.sol index 9608be5..4be8e70 100644 --- a/contracts/token/contracts/evm/UniversalToken.sol +++ b/contracts/token/contracts/evm/UniversalToken.sol @@ -1,19 +1,15 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.26; -import "@zetachain/protocol-contracts/contracts/evm/GatewayEVM.sol"; -import {RevertContext} from "@zetachain/protocol-contracts/contracts/Revert.sol"; import {ERC20Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol"; import {ERC20BurnableUpgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20BurnableUpgradeable.sol"; import {ERC20PausableUpgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20PausableUpgradeable.sol"; -import {ERC20PermitUpgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20PermitUpgradeable.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 {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; -import {ERC20BurnableUpgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20BurnableUpgradeable.sol"; -import "../shared/UniversalTokenEvents.sol"; +// Import the Universal Token core contract +import "./UniversalTokenCore.sol"; contract UniversalToken is Initializable, @@ -21,24 +17,9 @@ contract UniversalToken is ERC20BurnableUpgradeable, ERC20PausableUpgradeable, OwnableUpgradeable, - ERC20PermitUpgradeable, UUPSUpgradeable, - UniversalTokenEvents + UniversalTokenCore // Inherit the Universal Token core contract { - GatewayEVM public gateway; - address public universal; - uint256 public gasLimitAmount; - - error InvalidAddress(); - error Unauthorized(); - error InvalidGasLimit(); - error GasTokenTransferFailed(); - - modifier onlyGateway() { - if (msg.sender != address(gateway)) revert Unauthorized(); - _; - } - /// @custom:oz-upgrades-unsafe-allow constructor constructor() { _disableInitializers(); @@ -48,111 +29,34 @@ contract UniversalToken is address initialOwner, string memory name, string memory symbol, - address payable gatewayAddress, - uint256 gas + address payable gatewayAddress, // Include EVM gateway address + uint256 gas // Set gas limit for universal Token transfers ) public initializer { __ERC20_init(name, symbol); __ERC20Burnable_init(); + __ERC20Pausable_init(); __Ownable_init(initialOwner); __UUPSUpgradeable_init(); - if (gatewayAddress == address(0)) revert InvalidAddress(); - gasLimitAmount = gas; - gateway = GatewayEVM(gatewayAddress); + __UniversalTokenCore_init(gatewayAddress, address(this), gas); // Initialize the Universal Token core contract } - function setGasLimit(uint256 gas) external onlyOwner { - if (gas == 0) revert InvalidGasLimit(); - gasLimitAmount = gas; + function pause() public onlyOwner { + _pause(); } - function setUniversal(address contractAddress) external onlyOwner { - if (contractAddress == address(0)) revert InvalidAddress(); - universal = contractAddress; - emit SetUniversal(contractAddress); + function unpause() public onlyOwner { + _unpause(); } - function mint(address to, uint256 amount) public onlyOwner whenNotPaused { + function mint(address to, uint256 amount) public onlyOwner { _mint(to, amount); } - function transferCrossChain( - address destination, - address receiver, - uint256 amount - ) external payable whenNotPaused { - if (receiver == address(0)) revert InvalidAddress(); - _burn(msg.sender, amount); - - bytes memory message = abi.encode( - destination, - receiver, - amount, - 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(amount, msg.sender), - gasLimitAmount - ) - ); - } - - emit TokenTransfer(destination, receiver, amount); - } - - function onCall( - MessageContext calldata context, - bytes calldata message - ) external payable onlyGateway returns (bytes4) { - if (context.sender != universal) revert Unauthorized(); - ( - address receiver, - uint256 amount, - uint256 gasAmount, - address sender - ) = abi.decode(message, (address, uint256, uint256, address)); - _mint(receiver, amount); - if (gasAmount > 0) { - if (sender == address(0)) revert InvalidAddress(); - (bool success, ) = payable(sender).call{value: amount}(""); - if (!success) revert GasTokenTransferFailed(); - } - emit TokenTransferReceived(receiver, amount); - return ""; - } - - function onRevert(RevertContext calldata context) external onlyGateway { - (uint256 amount, address receiver) = abi.decode( - context.revertMessage, - (uint256, address) - ); - _mint(receiver, amount); - emit TokenTransferReverted(receiver, amount); - } - function _authorizeUpgrade( address newImplementation ) internal override onlyOwner {} - function pause() public onlyOwner { - _pause(); - } - - function unpause() public onlyOwner { - _unpause(); - } + // The following functions are overrides required by Solidity. function _update( address from, diff --git a/contracts/token/contracts/evm/UniversalTokenCore.sol b/contracts/token/contracts/evm/UniversalTokenCore.sol new file mode 100644 index 0000000..b469085 --- /dev/null +++ b/contracts/token/contracts/evm/UniversalTokenCore.sol @@ -0,0 +1,188 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +import "@zetachain/protocol-contracts/contracts/evm/GatewayEVM.sol"; +import {RevertOptions} from "@zetachain/protocol-contracts/contracts/evm/GatewayEVM.sol"; +import {ERC20Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol"; +import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; + +import "../shared/UniversalTokenEvents.sol"; + +/** + * @title UniversalTokenCore + * @dev This abstract contract provides the core logic for Universal Tokens. It is designed + * to be imported into an OpenZeppelin-based ERC20 implementation, extending its + * functionality with cross-chain token transfer capabilities via GatewayEVM. This + * contract facilitates cross-chain token transfers to and from EVM-based networks. + * It's important to set the universal contract address before making cross-chain transfers. + */ +abstract contract UniversalTokenCore is + ERC20Upgradeable, + OwnableUpgradeable, + UniversalTokenEvents +{ + // Address of the EVM gateway contract + GatewayEVM public gateway; + + // The address of the Universal Token contract on ZetaChain. This contract serves + // as a key component for handling all cross-chain transfers while also functioning + // as an ERC-20 Universal Token. + address public universal; + + // The amount of gas used when making cross-chain transfers + uint256 public gasLimitAmount; + + error InvalidAddress(); + error Unauthorized(); + error InvalidGasLimit(); + error GasTokenTransferFailed(); + + /** + * @dev Ensures that the function can only be called by the Gateway contract. + */ + modifier onlyGateway() { + if (msg.sender != address(gateway)) revert Unauthorized(); + _; + } + + /** + * @notice Sets the gas limit for cross-chain transfers. + * @dev Can only be called by the contract owner. + * @param gas New gas limit value. + */ + function setGasLimit(uint256 gas) external onlyOwner { + if (gas == 0) revert InvalidGasLimit(); + gasLimitAmount = gas; + } + + /** + * @notice Sets the universal contract address. + * @dev Can only be called by the contract owner. + * @param contractAddress The address of the universal contract. + */ + function setUniversal(address contractAddress) external onlyOwner { + if (contractAddress == address(0)) revert InvalidAddress(); + universal = contractAddress; + emit SetUniversal(contractAddress); + } + + /** + * @notice Sets the EVM gateway contract address. + * @dev Can only be called by the contract owner. + * @param gatewayAddress The address of the gateway contract. + */ + function setGateway(address gatewayAddress) external onlyOwner { + if (gatewayAddress == address(0)) revert InvalidAddress(); + gateway = GatewayEVM(gatewayAddress); + } + + /** + * @notice Initializes the contract with gateway, universal, and gas limit settings. + * @dev To be called during contract deployment. + * @param gatewayAddress The address of the gateway contract. + * @param universalAddress The address of the universal contract. + * @param gasLimit The gas limit to set. + */ + function __UniversalTokenCore_init( + address gatewayAddress, + address universalAddress, + uint256 gasLimit + ) internal { + if (gatewayAddress == address(0)) revert InvalidAddress(); + if (universalAddress == address(0)) revert InvalidAddress(); + if (gasLimit == 0) revert InvalidGasLimit(); + gateway = GatewayEVM(gatewayAddress); + universal = universalAddress; + gasLimitAmount = gasLimit; + } + + /** + * @notice Transfers tokens to another chain. + * @dev Burns the tokens locally, then uses the Gateway to send a message to + * mint the same tokens on the destination chain. If the destination is the zero + * address, transfers the tokens to ZetaChain. + * @param destination The ZRC-20 address of the gas token of the destination chain. + * @param receiver The address on the destination chain that will receive the tokens. + * @param amount The amount of tokens to transfer. + */ + function transferCrossChain( + address destination, + address receiver, + uint256 amount + ) external payable { + if (receiver == address(0)) revert InvalidAddress(); + + _burn(msg.sender, amount); + + bytes memory message = abi.encode( + destination, + receiver, + amount, + msg.sender + ); + + emit TokenTransfer(destination, receiver, amount); + + 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(amount, msg.sender), + gasLimitAmount + ) + ); + } + } + + /** + * @notice Mints tokens in response to an incoming cross-chain transfer. + * @dev Called by the Gateway upon receiving a message. + * @param context The message context. + * @param message The encoded message containing information about the tokens. + * @return A constant indicating the function was successfully handled. + */ + function onCall( + MessageContext calldata context, + bytes calldata message + ) external payable onlyGateway returns (bytes4) { + if (context.sender != universal) revert Unauthorized(); + ( + address receiver, + uint256 amount, + uint256 gasAmount, + address sender + ) = abi.decode(message, (address, uint256, uint256, address)); + _mint(receiver, amount); + if (gasAmount > 0) { + if (sender == address(0)) revert InvalidAddress(); + (bool success, ) = payable(sender).call{value: gasAmount}(""); + if (!success) revert GasTokenTransferFailed(); + } + emit TokenTransferReceived(receiver, amount); + return ""; + } + + /** + * @notice Mints tokens and sends them back to the sender if a cross-chain transfer fails. + * @dev Called by the Gateway if a call fails. + * @param context The revert context containing metadata and revert message. + */ + function onRevert(RevertContext calldata context) external onlyGateway { + (uint256 amount, address receiver) = abi.decode( + context.revertMessage, + (uint256, address) + ); + _mint(receiver, amount); + emit TokenTransferReverted(receiver, amount); + } +} diff --git a/contracts/token/contracts/zetachain/UniversalToken.sol b/contracts/token/contracts/zetachain/UniversalToken.sol index b5ed54f..60ac5a9 100644 --- a/contracts/token/contracts/zetachain/UniversalToken.sol +++ b/contracts/token/contracts/zetachain/UniversalToken.sol @@ -6,17 +6,16 @@ import "@zetachain/protocol-contracts/contracts/zevm/interfaces/UniversalContrac 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 {ERC20BurnableUpgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20BurnableUpgradeable.sol"; import {ERC20Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol"; import {ERC20BurnableUpgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20BurnableUpgradeable.sol"; import {ERC20PausableUpgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20PausableUpgradeable.sol"; -import {ERC20PermitUpgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20PermitUpgradeable.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 {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; -import {ERC20BurnableUpgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20BurnableUpgradeable.sol"; -import "../shared/UniversalTokenEvents.sol"; +// Import the Universal Token core contract +import "./UniversalTokenCore.sol"; contract UniversalToken is Initializable, @@ -24,32 +23,9 @@ contract UniversalToken is ERC20BurnableUpgradeable, ERC20PausableUpgradeable, OwnableUpgradeable, - ERC20PermitUpgradeable, UUPSUpgradeable, - UniversalContract, - UniversalTokenEvents + UniversalTokenCore // Inherit the Universal Token core contract { - bool public constant isUniversal = true; - - GatewayZEVM public gateway; - address public uniswapRouter; - uint256 private _nextTokenId; - uint256 public gasLimitAmount; - - error TransferFailed(); - error Unauthorized(); - error InvalidAddress(); - error InvalidGasLimit(); - error ApproveFailed(); - error ZeroMsgValue(); - - mapping(address => address) public connected; - - modifier onlyGateway() { - if (msg.sender != address(gateway)) revert Unauthorized(); - _; - } - /// @custom:oz-upgrades-unsafe-allow constructor constructor() { _disableInitializers(); @@ -59,150 +35,15 @@ contract UniversalToken is address initialOwner, string memory name, string memory symbol, - address payable gatewayAddress, - uint256 gas, - address uniswapRouterAddress + address payable gatewayAddress, // Include EVM gateway address + uint256 gas, // Set gas limit for universal Token transfers + address uniswapRouterAddress // Uniswap v2 router address for gas token swaps ) public initializer { __ERC20_init(name, symbol); __ERC20Burnable_init(); __Ownable_init(initialOwner); __UUPSUpgradeable_init(); - if (gatewayAddress == address(0) || uniswapRouterAddress == address(0)) - revert InvalidAddress(); - if (gas == 0) revert InvalidGasLimit(); - gateway = GatewayZEVM(gatewayAddress); - uniswapRouter = uniswapRouterAddress; - gasLimitAmount = gas; - } - - function setGasLimit(uint256 gas) external onlyOwner { - if (gas == 0) revert InvalidGasLimit(); - gasLimitAmount = gas; - } - - function setConnected( - address zrc20, - address contractAddress - ) external onlyOwner { - connected[zrc20] = contractAddress; - emit SetConnected(zrc20, contractAddress); - } - - function transferCrossChain( - address destination, - address receiver, - uint256 amount - ) public payable whenNotPaused { - if (msg.value == 0) revert ZeroMsgValue(); - if (receiver == address(0)) revert InvalidAddress(); - _burn(msg.sender, amount); - - (address gasZRC20, uint256 gasFee) = IZRC20(destination) - .withdrawGasFeeWithGasLimit(gasLimitAmount); - if (destination != gasZRC20) revert InvalidAddress(); - - address WZETA = gateway.zetaToken(); - - IWETH9(WZETA).deposit{value: msg.value}(); - IWETH9(WZETA).approve(uniswapRouter, msg.value); - - uint256 out = SwapHelperLib.swapTokensForExactTokens( - uniswapRouter, - WZETA, - gasFee, - gasZRC20, - msg.value - ); - - uint256 remaining = msg.value - out; - - if (remaining > 0) { - IWETH9(WZETA).withdraw(remaining); - (bool success, ) = msg.sender.call{value: remaining}(""); - if (!success) revert TransferFailed(); - } - - bytes memory message = abi.encode(receiver, amount, 0, msg.sender); - - CallOptions memory callOptions = CallOptions(gasLimitAmount, false); - - RevertOptions memory revertOptions = RevertOptions( - address(this), - true, - address(0), - abi.encode(amount, msg.sender), - gasLimitAmount - ); - - IZRC20(gasZRC20).approve(address(gateway), gasFee); - gateway.call( - abi.encodePacked(connected[destination]), - destination, - message, - callOptions, - revertOptions - ); - emit TokenTransfer(destination, receiver, amount); - } - - function mint(address to, uint256 amount) public onlyOwner whenNotPaused { - _mint(to, amount); - } - - 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 tokenAmount, - address sender - ) = abi.decode(message, (address, address, uint256, address)); - if (destination == address(0)) { - _mint(receiver, tokenAmount); - } 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, tokenAmount, out - gasFee, sender), - CallOptions(gasLimitAmount, false), - RevertOptions( - address(this), - true, - address(0), - abi.encode(tokenAmount, sender), - 0 - ) - ); - } - emit TokenTransferToDestination(destination, receiver, amount); - } - - function onRevert(RevertContext calldata context) external onlyGateway { - (uint256 amount, address sender) = abi.decode( - context.revertMessage, - (uint256, address) - ); - _mint(sender, amount); - emit TokenTransferReverted(sender, amount); + __UniversalTokenCore_init(gatewayAddress, gas, uniswapRouterAddress); // Initialize the Universal Token core contract } function pause() public onlyOwner { @@ -213,12 +54,14 @@ contract UniversalToken is _unpause(); } + function mint(address to, uint256 amount) public onlyOwner { + _mint(to, amount); + } + function _authorizeUpgrade( address newImplementation ) internal override onlyOwner {} - receive() external payable {} - // The following functions are overrides required by Solidity. function _update( @@ -228,4 +71,6 @@ contract UniversalToken is ) internal override(ERC20Upgradeable, ERC20PausableUpgradeable) { super._update(from, to, value); } + + receive() external payable {} } diff --git a/contracts/token/contracts/zetachain/UniversalTokenCore.sol b/contracts/token/contracts/zetachain/UniversalTokenCore.sol new file mode 100644 index 0000000..c49ee3b --- /dev/null +++ b/contracts/token/contracts/zetachain/UniversalTokenCore.sol @@ -0,0 +1,253 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +import {ERC20Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol"; +import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.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/interfaces/IWZETA.sol"; +import "@zetachain/protocol-contracts/contracts/zevm/GatewayZEVM.sol"; +import {SwapHelperLib} from "@zetachain/toolkit/contracts/SwapHelperLib.sol"; + +import "../shared/UniversalTokenEvents.sol"; + +/** + * @title UniversalTokenCore + * @dev This abstract contract provides the core logic for Universal Tokens. It is designed + * to be imported into an OpenZeppelin-based ERC20 implementation, extending its + * functionality with cross-chain token transfer capabilities via GatewayZEVM. This + * contract facilitates cross-chain token transfers to and from ZetaChain and other + * connected EVM-based networks. + */ +abstract contract UniversalTokenCore is + UniversalContract, + ERC20Upgradeable, + OwnableUpgradeable, + UniversalTokenEvents +{ + // Indicates this contract implements a Universal Contract + bool public constant isUniversal = true; + + // Address of the ZetaChain Gateway contract + GatewayZEVM public gateway; + + // Address of the Uniswap Router for token swaps + address public uniswapRouter; + + // Gas limit for cross-chain operations + uint256 public gasLimitAmount; + + // Mapping of connected ZRC20 tokens to their respective contracts + mapping(address => address) public connected; + + error TransferFailed(); + error Unauthorized(); + error InvalidAddress(); + error InvalidGasLimit(); + error ApproveFailed(); + error ZeroMsgValue(); + + modifier onlyGateway() { + if (msg.sender != address(gateway)) revert Unauthorized(); + _; + } + + /** + * @notice Sets the ZetaChain gateway contract address. + * @dev Can only be called by the contract owner. + * @param gatewayAddress The address of the gateway contract. + */ + function setGateway(address gatewayAddress) external onlyOwner { + if (gatewayAddress == address(0)) revert InvalidAddress(); + gateway = GatewayZEVM(payable(gatewayAddress)); + } + + /** + * @notice Initializes the contract. + * @dev Should be called during contract deployment. + * @param gatewayAddress Address of the Gateway contract. + * @param gasLimit Gas limit for cross-chain calls. + * @param uniswapRouterAddress Address of the Uniswap router contract. + */ + function __UniversalTokenCore_init( + address gatewayAddress, + uint256 gasLimit, + address uniswapRouterAddress + ) internal { + if (gatewayAddress == address(0) || uniswapRouterAddress == address(0)) + revert InvalidAddress(); + if (gasLimit == 0) revert InvalidGasLimit(); + gateway = GatewayZEVM(payable(gatewayAddress)); + uniswapRouter = uniswapRouterAddress; + gasLimitAmount = gasLimit; + } + + /** + * @notice Sets the gas limit for cross-chain transfers. + * @dev Can only be called by the contract owner. + * @param gas New gas limit value. + */ + function setGasLimit(uint256 gas) external onlyOwner { + if (gas == 0) revert InvalidGasLimit(); + gasLimitAmount = gas; + } + + /** + * @notice Links a ZRC20 gas token address to a contract on the corresponding chain. + * @dev Can only be called by the contract owner. + * @param zrc20 Address of the ZRC20 token. + * @param contractAddress Address of the corresponding contract. + */ + function setConnected( + address zrc20, + address contractAddress + ) external onlyOwner { + connected[zrc20] = contractAddress; + emit SetConnected(zrc20, contractAddress); + } + + /** + * @notice Transfers tokens to a connected chain. + * @dev This function accepts native ZETA tokens as gas fees, which are swapped + * for the corresponding ZRC20 gas token of the destination chain. The tokens are then + * transferred to the destination chain using the ZetaChain Gateway. + * @param destination Address of the ZRC20 gas token for the destination chain. + * @param receiver Address of the recipient on the destination chain. + * @param amount Amount of tokens to transfer. + */ + function transferCrossChain( + address destination, + address receiver, + uint256 amount + ) public payable { + if (msg.value == 0) revert ZeroMsgValue(); + if (receiver == address(0)) revert InvalidAddress(); + + _burn(msg.sender, amount); + + emit TokenTransfer(destination, receiver, amount); + + (address gasZRC20, uint256 gasFee) = IZRC20(destination) + .withdrawGasFeeWithGasLimit(gasLimitAmount); + if (destination != gasZRC20) revert InvalidAddress(); + + address WZETA = gateway.zetaToken(); + + IWETH9(WZETA).deposit{value: msg.value}(); + if (!IWETH9(WZETA).approve(uniswapRouter, msg.value)) { + revert ApproveFailed(); + } + + uint256 out = SwapHelperLib.swapTokensForExactTokens( + uniswapRouter, + WZETA, + gasFee, + gasZRC20, + msg.value + ); + + uint256 remaining = msg.value - out; + + if (remaining > 0) { + IWETH9(WZETA).withdraw(remaining); + (bool success, ) = msg.sender.call{value: remaining}(""); + if (!success) revert TransferFailed(); + } + + bytes memory message = abi.encode(receiver, amount, 0, msg.sender); + + CallOptions memory callOptions = CallOptions(gasLimitAmount, false); + + RevertOptions memory revertOptions = RevertOptions( + address(this), + true, + address(0), + abi.encode(amount, msg.sender), + gasLimitAmount + ); + + if (!IZRC20(gasZRC20).approve(address(gateway), gasFee)) { + revert ApproveFailed(); + } + gateway.call( + abi.encodePacked(connected[destination]), + destination, + message, + callOptions, + revertOptions + ); + } + + /** + * @notice Handles cross-chain token transfers. + * @dev This function is called by the Gateway contract upon receiving a message. + * If the destination is ZetaChain, mint tokens for the receiver. + * If the destination is another chain, swap the gas token for the corresponding + * ZRC20 token and use the Gateway to send a message to transfer tokens to the + * destination chain. + * @param context Message context metadata. + * @param zrc20 ZRC20 token address. + * @param amount Amount of token provided. + * @param message Encoded payload containing token transfer metadata. + */ + 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 tokenAmount, + address sender + ) = abi.decode(message, (address, address, uint256, address)); + + if (destination == address(0)) { + _mint(receiver, tokenAmount); + } 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, tokenAmount, out - gasFee, sender), + CallOptions(gasLimitAmount, false), + RevertOptions( + address(this), + true, + address(0), + abi.encode(tokenAmount, sender), + 0 + ) + ); + } + emit TokenTransferToDestination(destination, receiver, amount); + } + + /** + * @notice Handles a cross-chain call failure and reverts the token transfer. + * @param context Metadata about the failed call. + */ + function onRevert(RevertContext calldata context) external onlyGateway { + (uint256 amount, address sender) = abi.decode( + context.revertMessage, + (uint256, address) + ); + _mint(sender, amount); + emit TokenTransferReverted(sender, amount); + } +}