From 9a852e6ac6e19719d14f5db34725ad2e1425a938 Mon Sep 17 00:00:00 2001 From: Denis Fadeev Date: Fri, 8 Nov 2024 10:21:01 +0300 Subject: [PATCH] NFT: improvements to make production-ready (#217) --- examples/nft/contracts/Connected.sol | 61 ++++++++++++------- examples/nft/contracts/Universal.sol | 44 ++++++++----- examples/nft/contracts/shared/Events.sol | 30 +++++++++ examples/nft/scripts/test.sh | 26 ++++---- .../nft/tasks/connectedSetCounterparty.ts | 8 ++- examples/nft/tasks/deploy.ts | 15 ++++- examples/nft/tasks/mint.ts | 8 ++- examples/nft/tasks/transfer.ts | 1 + .../nft/tasks/universalSetCounterparty.ts | 8 ++- examples/nft/tsconfig.json | 4 ++ 10 files changed, 142 insertions(+), 63 deletions(-) create mode 100644 examples/nft/contracts/shared/Events.sol diff --git a/examples/nft/contracts/Connected.sol b/examples/nft/contracts/Connected.sol index 8e86c867..6b561403 100644 --- a/examples/nft/contracts/Connected.sol +++ b/examples/nft/contracts/Connected.sol @@ -4,43 +4,56 @@ 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/access/Ownable.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"; - -contract Connected is ERC721, ERC721Enumerable, ERC721URIStorage, Ownable { +import "./shared/Events.sol"; + +contract Connected is + ERC721, + ERC721Enumerable, + ERC721URIStorage, + Ownable2Step, + Events +{ GatewayEVM public immutable gateway; uint256 private _nextTokenId; address public counterparty; + error InvalidAddress(); + error Unauthorized(); + function setCounterparty(address contractAddress) external onlyOwner { + if (contractAddress == address(0)) revert InvalidAddress(); counterparty = contractAddress; + emit SetCounterparty(contractAddress); } modifier onlyGateway() { - require(msg.sender == address(gateway), "Caller is not the gateway"); + if (msg.sender != address(gateway)) revert Unauthorized(); _; } constructor( address payable gatewayAddress, - address initialOwner - ) ERC721("MyToken", "MTK") Ownable(initialOwner) { + address owner, + string memory name, + string memory symbol + ) ERC721(name, symbol) Ownable(owner) { + if (gatewayAddress == address(0) || owner == address(0)) + revert InvalidAddress(); gateway = GatewayEVM(gatewayAddress); } function safeMint(address to, string memory uri) public onlyOwner { - uint256 hash = uint256( - keccak256( - abi.encodePacked(address(this), block.number, _nextTokenId++) - ) - ); + if (to == address(0)) revert InvalidAddress(); - uint256 tokenId = hash & 0x00FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF; + uint256 tokenId = _nextTokenId++; _safeMint(to, tokenId); _setTokenURI(tokenId, uri); + emit TokenMinted(to, tokenId, uri); } function transferCrossChain( @@ -48,46 +61,47 @@ contract Connected is ERC721, ERC721Enumerable, ERC721URIStorage, Ownable { address receiver, address destination ) external payable { + if (receiver == address(0)) revert InvalidAddress(); + string memory uri = tokenURI(tokenId); _burn(tokenId); - bytes memory encodedData = abi.encode( - tokenId, - receiver, - uri, - destination - ); + bytes memory message = abi.encode(tokenId, receiver, uri, destination); RevertOptions memory revertOptions = RevertOptions( address(this), true, address(0), - encodedData, + message, 0 ); if (destination == address(0)) { - gateway.call(counterparty, encodedData, revertOptions); + gateway.call(counterparty, message, revertOptions); } else { gateway.depositAndCall{value: msg.value}( counterparty, - encodedData, + message, revertOptions ); } + + emit TokenTransfer(tokenId, receiver, destination, uri); } function onCall( - MessageContext calldata messageContext, + MessageContext calldata context, bytes calldata message ) external payable onlyGateway returns (bytes4) { - if (messageContext.sender != counterparty) revert("Unauthorized"); + if (context.sender != counterparty) revert Unauthorized(); (uint256 tokenId, address receiver, string memory uri) = abi.decode( message, (uint256, address, string) ); + _safeMint(receiver, tokenId); _setTokenURI(tokenId, uri); + emit TokenTransferReceived(tokenId, receiver, uri); return ""; } @@ -99,6 +113,7 @@ contract Connected is ERC721, ERC721Enumerable, ERC721URIStorage, Ownable { _safeMint(sender, tokenId); _setTokenURI(tokenId, uri); + emit TokenTransferReverted(tokenId, sender, uri); } receive() external payable {} diff --git a/examples/nft/contracts/Universal.sol b/examples/nft/contracts/Universal.sol index a6f05a19..f323f5f4 100644 --- a/examples/nft/contracts/Universal.sol +++ b/examples/nft/contracts/Universal.sol @@ -4,45 +4,55 @@ 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/access/Ownable.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"; contract Universal is ERC721, ERC721Enumerable, ERC721URIStorage, - Ownable, - UniversalContract + Ownable2Step, + UniversalContract, + Events { GatewayZEVM public immutable gateway; SystemContract public immutable systemContract = SystemContract(0xCf7Ed3AccA5a467e9e704C703E8D87F634fB0Fc9); uint256 private _nextTokenId; bool public isUniversal = true; - uint256 public gasLimit = 700000; + uint256 public gasLimit; error TransferFailed(); + error Unauthorized(); + error InvalidAddress(); + error InvalidGasLimit(); mapping(address => bytes) public counterparty; - event CounterpartySet(address indexed zrc20, bytes indexed contractAddress); - modifier onlyGateway() { - require(msg.sender == address(gateway), "Caller is not the gateway"); + if (msg.sender != address(gateway)) revert Unauthorized(); _; } constructor( address payable gatewayAddress, - address initialOwner - ) ERC721("MyToken", "MTK") Ownable(initialOwner) { + address owner, + string memory name, + string memory symbol, + uint256 gas + ) ERC721(name, symbol) Ownable(owner) { + if (gatewayAddress == address(0) || owner == address(0)) + revert InvalidAddress(); + if (gas == 0) revert InvalidGasLimit(); gateway = GatewayZEVM(gatewayAddress); + gasLimit = gas; } function setCounterparty( @@ -58,6 +68,7 @@ contract Universal is address receiver, address destination ) public { + if (receiver == address(0)) revert InvalidAddress(); string memory uri = tokenURI(tokenId); _burn(tokenId); @@ -68,7 +79,7 @@ contract Universal is !IZRC20(destination).transferFrom(msg.sender, address(this), gasFee) ) revert TransferFailed(); IZRC20(destination).approve(address(gateway), gasFee); - bytes memory encodedData = abi.encode(tokenId, receiver, uri); + bytes memory message = abi.encode(tokenId, receiver, uri); CallOptions memory callOptions = CallOptions(gasLimit, false); @@ -76,17 +87,19 @@ contract Universal is address(this), true, address(0), - encodedData, + message, gasLimit ); gateway.call( counterparty[destination], destination, - encodedData, + message, callOptions, revertOptions ); + + emit TokenTransfer(tokenId, receiver, destination, uri); } function safeMint(address to, string memory uri) public onlyOwner { @@ -121,9 +134,10 @@ contract Universal is if (destination == address(0)) { _safeMint(sender, tokenId); _setTokenURI(tokenId, uri); + emit TokenTransferReceived(tokenId, sender, uri); } else { (, uint256 gasFee) = IZRC20(destination).withdrawGasFeeWithGasLimit( - 700000 + gasLimit ); SwapHelperLib.swapExactTokensForTokens( @@ -139,9 +153,10 @@ contract Universal is counterparty[destination], destination, abi.encode(tokenId, sender, uri), - CallOptions(700000, false), + CallOptions(gasLimit, false), RevertOptions(address(0), false, address(0), "", 0) ); + emit TokenTransferToDestination(tokenId, sender, destination, uri); } } @@ -153,6 +168,7 @@ contract Universal is _safeMint(sender, tokenId); _setTokenURI(tokenId, uri); + emit TokenTransferReverted(tokenId, sender, uri); } // The following functions are overrides required by Solidity. diff --git a/examples/nft/contracts/shared/Events.sol b/examples/nft/contracts/shared/Events.sol new file mode 100644 index 00000000..a0beba21 --- /dev/null +++ b/examples/nft/contracts/shared/Events.sol @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +contract Events { + event SetCounterparty(address indexed newCounterparty); + event TokenMinted(address indexed to, uint256 indexed tokenId, string uri); + event TokenTransfer( + uint256 indexed tokenId, + address indexed receiver, + address indexed destination, + string uri + ); + event TokenTransferReceived( + uint256 indexed tokenId, + address indexed receiver, + string uri + ); + event TokenTransferReverted( + uint256 indexed tokenId, + address indexed sender, + string uri + ); + event CounterpartySet(address indexed zrc20, bytes indexed contractAddress); + event TokenTransferToDestination( + uint256 indexed tokenId, + address indexed sender, + address indexed destination, + string uri + ); +} diff --git a/examples/nft/scripts/test.sh b/examples/nft/scripts/test.sh index 88c4f1c6..4b357c5b 100755 --- a/examples/nft/scripts/test.sh +++ b/examples/nft/scripts/test.sh @@ -2,18 +2,16 @@ set -e -if [ "$1" = "localnet" ]; then - npx hardhat localnet --exit-on-error & sleep 10 -fi +if [ "$1" = "localnet" ]; then npx hardhat localnet --exit-on-error & sleep 10; fi -function nft_balance() { +function balance() { local ZETACHAIN=$(cast call "$CONTRACT_ZETACHAIN" "balanceOf(address)(uint256)" "$SENDER") local ETHEREUM=$(cast call "$CONTRACT_ETHEREUM" "balanceOf(address)(uint256)" "$SENDER") local BNB=$(cast call "$CONTRACT_BNB" "balanceOf(address)(uint256)" "$SENDER") echo -e "\nšŸ–¼ļø NFT Balance" echo "---------------------------------------------" echo "šŸŸ¢ ZetaChain: $ZETACHAIN" - echo "šŸ”µ EVM Chain: $ETHEREUM" + echo "šŸ”µ Ethereum: $ETHEREUM" echo "šŸŸ” BNB Chain: $BNB" echo "---------------------------------------------" } @@ -27,11 +25,11 @@ GATEWAY_ETHEREUM=$(jq -r '.addresses[] | select(.type=="gatewayEVM" and .chain== GATEWAY_BNB=$(jq -r '.addresses[] | select(.type=="gatewayEVM" and .chain=="bnb") | .address' localnet.json) SENDER=0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 -CONTRACT_ZETACHAIN=$(npx hardhat deploy --network localhost --json | jq -r '.contractAddress') +CONTRACT_ZETACHAIN=$(npx hardhat deploy --network localhost --json --gas-limit 1000000 | jq -r '.contractAddress') echo -e "\nšŸš€ Deployed NFT contract on ZetaChain: $CONTRACT_ZETACHAIN" CONTRACT_ETHEREUM=$(npx hardhat deploy --name Connected --json --network localhost --gateway "$GATEWAY_ETHEREUM" | jq -r '.contractAddress') -echo -e "šŸš€ Deployed NFT contract on EVM chain: $CONTRACT_ETHEREUM" +echo -e "šŸš€ Deployed NFT contract on Ethereum: $CONTRACT_ETHEREUM" CONTRACT_BNB=$(npx hardhat deploy --name Connected --json --network localhost --gateway "$GATEWAY_BNB" | jq -r '.contractAddress') echo -e "šŸš€ Deployed NFT contract on BNB chain: $CONTRACT_BNB" @@ -45,30 +43,30 @@ npx hardhat universal-set-counterparty --network localhost --contract "$CONTRACT npx hardhat universal-set-counterparty --network localhost --contract "$CONTRACT_ZETACHAIN" --counterparty "$CONTRACT_BNB" --zrc20 "$ZRC20_BNB" --json &>/dev/null npx hardhat localnet-check -nft_balance +balance NFT_ID=$(npx hardhat mint --network localhost --json --contract "$CONTRACT_ZETACHAIN" --token-uri https://example.com/nft/metadata/1 | jq -r '.tokenId') echo -e "\nMinted NFT with ID: $NFT_ID on ZetaChain." npx hardhat localnet-check -nft_balance +balance echo -e "\nTransferring NFT: ZetaChain ā†’ Ethereum..." -npx hardhat transfer --network localhost --json --token-id "$NFT_ID" --from "$CONTRACT_ZETACHAIN" --to "$ZRC20_ETHEREUM" +npx hardhat transfer --network localhost --json --token-id "$NFT_ID" --from "$CONTRACT_ZETACHAIN" --to "$ZRC20_ETHEREUM" npx hardhat localnet-check -nft_balance +balance echo -e "\nTransferring NFT: Ethereum ā†’ BNB..." npx hardhat transfer --network localhost --json --token-id "$NFT_ID" --from "$CONTRACT_ETHEREUM" --to "$ZRC20_BNB" --gas-amount 0.1 npx hardhat localnet-check -nft_balance +balance echo -e "\nTransferring NFT: BNB ā†’ ZetaChain..." npx hardhat transfer --network localhost --json --token-id "$NFT_ID" --from "$CONTRACT_BNB" npx hardhat localnet-check -nft_balance +balance -npx hardhat localnet-stop \ No newline at end of file +if [ "$1" = "localnet" ]; then npx hardhat localnet-stop; fi \ No newline at end of file diff --git a/examples/nft/tasks/connectedSetCounterparty.ts b/examples/nft/tasks/connectedSetCounterparty.ts index d9bec15b..92281b4d 100644 --- a/examples/nft/tasks/connectedSetCounterparty.ts +++ b/examples/nft/tasks/connectedSetCounterparty.ts @@ -1,6 +1,6 @@ import { task } from "hardhat/config"; import { HardhatRuntimeEnvironment } from "hardhat/types"; -import { ethers } from "ethers"; +import { Connected } from "@/typechain-types"; const main = async (args: any, hre: HardhatRuntimeEnvironment) => { const [signer] = await hre.ethers.getSigners(); @@ -10,7 +10,10 @@ const main = async (args: any, hre: HardhatRuntimeEnvironment) => { ); } - const contract = await hre.ethers.getContractAt(args.name, args.contract); + const contract: Connected = await hre.ethers.getContractAt( + "Connected", + args.contract + ); const tx = await contract.setCounterparty(args.counterparty); const receipt = await tx.wait(); @@ -34,5 +37,4 @@ const main = async (args: any, hre: HardhatRuntimeEnvironment) => { task("connected-set-counterparty", "Sets the universal contract address", main) .addParam("contract", "The address of the deployed contract") .addParam("counterparty", "The address of the universal contract to set") - .addOptionalParam("name", "The contract name to interact with", "Connected") .addFlag("json", "Output the result in JSON format"); diff --git a/examples/nft/tasks/deploy.ts b/examples/nft/tasks/deploy.ts index 1421fab3..895512b0 100644 --- a/examples/nft/tasks/deploy.ts +++ b/examples/nft/tasks/deploy.ts @@ -1,4 +1,4 @@ -import { task, types } from "hardhat/config"; +import { task } from "hardhat/config"; import { HardhatRuntimeEnvironment } from "hardhat/types"; const main = async (args: any, hre: HardhatRuntimeEnvironment) => { @@ -11,8 +11,14 @@ const main = async (args: any, hre: HardhatRuntimeEnvironment) => { ); } - const factory = await hre.ethers.getContractFactory(args.name); - const contract = await factory.deploy(args.gateway, signer.address); + const factory: any = await hre.ethers.getContractFactory(args.name); + const contract = await factory.deploy( + args.gateway, + signer.address, + args.nftName, + args.nftSymbol, + ...(args.gasLimit ? [args.gasLimit] : []) + ); await contract.deployed(); if (args.json) { @@ -33,7 +39,10 @@ const main = async (args: any, hre: HardhatRuntimeEnvironment) => { task("deploy", "Deploy the NFT contract", main) .addFlag("json", "Output the result in JSON format") + .addOptionalParam("nftName", "NFT name", "Universal NFT") + .addOptionalParam("nftSymbol", "NFT symbol", "UNFT") .addOptionalParam("name", "The contract name to deploy", "Universal") + .addOptionalParam("gasLimit", "Gas limit for the transaction") .addOptionalParam( "gateway", "Gateway address (default: ZetaChain Gateway)", diff --git a/examples/nft/tasks/mint.ts b/examples/nft/tasks/mint.ts index 515ba6bb..7806e3d4 100644 --- a/examples/nft/tasks/mint.ts +++ b/examples/nft/tasks/mint.ts @@ -1,6 +1,5 @@ import { task } from "hardhat/config"; import { HardhatRuntimeEnvironment } from "hardhat/types"; -import { ethers } from "ethers"; const main = async (args: any, hre: HardhatRuntimeEnvironment) => { const [signer] = await hre.ethers.getSigners(); @@ -10,7 +9,10 @@ const main = async (args: any, hre: HardhatRuntimeEnvironment) => { ); } - const contract = await hre.ethers.getContractAt(args.name, args.contract); + const contract = await hre.ethers.getContractAt( + args.name as "Universal" | "Connected", + args.contract + ); const recipient = args.to || signer.address; @@ -18,7 +20,7 @@ const main = async (args: any, hre: HardhatRuntimeEnvironment) => { const receipt = await tx.wait(); const transferEvent = receipt.events?.find( - (event) => event.event === "Transfer" + (event: any) => event.event === "Transfer" ); const tokenId = transferEvent?.args?.tokenId; diff --git a/examples/nft/tasks/transfer.ts b/examples/nft/tasks/transfer.ts index 484f8cb3..e0e47abe 100644 --- a/examples/nft/tasks/transfer.ts +++ b/examples/nft/tasks/transfer.ts @@ -61,6 +61,7 @@ const main = async (args: any, hre: HardhatRuntimeEnvironment) => { }; task("transfer", "Transfer and lock an NFT", main) + .addOptionalParam("receiver", "The address to receive the NFT") .addParam("from", "The contract being transferred from") .addParam("tokenId", "The ID of the NFT to transfer") .addOptionalParam( diff --git a/examples/nft/tasks/universalSetCounterparty.ts b/examples/nft/tasks/universalSetCounterparty.ts index 6a3ff107..9fd2e13e 100644 --- a/examples/nft/tasks/universalSetCounterparty.ts +++ b/examples/nft/tasks/universalSetCounterparty.ts @@ -1,5 +1,6 @@ import { task } from "hardhat/config"; import { HardhatRuntimeEnvironment } from "hardhat/types"; +import { Universal } from "@/typechain-types"; const main = async (args: any, hre: HardhatRuntimeEnvironment) => { const [signer] = await hre.ethers.getSigners(); @@ -9,10 +10,12 @@ const main = async (args: any, hre: HardhatRuntimeEnvironment) => { ); } - const contract = await hre.ethers.getContractAt(args.name, args.contract); + const contract: Universal = await hre.ethers.getContractAt( + "Universal", + args.contract + ); const tx = await contract.setCounterparty(args.zrc20, args.counterparty); - const receipt = await tx.wait(); if (args.json) { console.log( @@ -36,5 +39,4 @@ task("universal-set-counterparty", "Sets the connected contract address", main) .addParam("contract", "The address of the deployed contract") .addParam("zrc20", "The ZRC20 address to link to the connected contract") .addParam("counterparty", "The address of the connected contract to set") - .addOptionalParam("name", "The contract name to interact with", "Universal") .addFlag("json", "Output the result in JSON format"); diff --git a/examples/nft/tsconfig.json b/examples/nft/tsconfig.json index fb0567b3..705790bf 100644 --- a/examples/nft/tsconfig.json +++ b/examples/nft/tsconfig.json @@ -1,5 +1,9 @@ { "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["./*"] + }, "module": "nodenext", "moduleResolution": "nodenext", "esModuleInterop": true,