diff --git a/contracts/game/NFTLockV2.sol b/contracts/game/NFTLockV2.sol new file mode 100644 index 0000000..99ffaa0 --- /dev/null +++ b/contracts/game/NFTLockV2.sol @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.19; +import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; +import {Pausable} from "@openzeppelin/contracts/security/Pausable.sol"; +import {HasSignature} from "../core/HasSignature.sol"; +import {TimeChecker} from "../utils/TimeChecker.sol"; + +interface INFT { + function mint(address to, uint256 tokenID) external; + function transferFrom(address from, address to, uint256 tokenId) external; + function burn(uint256 tokenId) external; + function ownerOf(uint256 tokenId) external view returns (address); +} + +contract NFTLockV2 is HasSignature, TimeChecker, Pausable { + using EnumerableSet for EnumerableSet.UintSet; + + uint256 public immutable _CACHED_CHAIN_ID; + address public immutable _CACHED_THIS; + address public verifier; + uint256 public maxBatch = 100; + + struct NFTInfo { + uint256 tokenId; + address to; + bool isMint; + } + mapping(address nft => bool status) public supportNftList; + + event UnLock(address indexed nft, address indexed user, uint256 nonce, NFTInfo[] nftList); + event Lock(address indexed nft, address indexed sender, address indexed to, uint256[] tokenIds); + event VerifierUpdated(address indexed verifier); + + constructor(uint256 _duration, address _verifier) TimeChecker(_duration) { + _CACHED_CHAIN_ID = block.chainid; + _CACHED_THIS = address(this); + verifier = _verifier; + } + /** + * from eoa or passport + */ + function lock(address nft, address to, uint256[] calldata tokenIds) external whenNotPaused{ + require(tokenIds.length <= maxBatch, "tokenIds too many"); + require(to != address(0), "to can't be zero"); + require(supportNftList[nft], "not support nft"); + address _sender = _msgSender(); + for (uint256 i = 0; i < tokenIds.length; i++) { + address owner = INFT(nft).ownerOf(tokenIds[i]); + require(owner == _sender, "not owner"); + INFT(nft).burn(tokenIds[i]); + } + emit Lock(nft, _sender, to, tokenIds); + } + /** + * @dev mint nft + */ + function unlockOrMint( + address nft, + NFTInfo[] calldata nftList, + uint256 signTime, + uint256 saltNonce, + bytes calldata signature + ) external signatureValid(signature) timeValid(signTime) { + require(nftList.length <= maxBatch, "tokenIds too many"); + address _sender = _msgSender(); + bytes32 messageHash = getMessageHash(_sender, nft, nftList, _CACHED_THIS, _CACHED_CHAIN_ID, signTime, saltNonce); + checkSigner(verifier, messageHash, signature); + _useSignature(signature); + for (uint256 i = 0; i < nftList.length; i++) { + require(nftList[i].isMint, "mint only"); + INFT(nft).mint(nftList[i].to, nftList[i].tokenId); + } + emit UnLock(nft, _sender, saltNonce, nftList); + } + + function addSupportNftList(address[] calldata nftList) external onlyOwner { + for (uint256 i = 0; i < nftList.length; i++) { + supportNftList[nftList[i]] = true; + } + } + + function removeSupportNft(address nftAddress) external onlyOwner { + require(supportNftList[nftAddress], "can't remove"); + delete supportNftList[nftAddress]; + } + + /** + * @dev update verifier address + */ + function updateVerifier(address _verifier) external onlyOwner { + require(_verifier != address(0), "address can not be zero"); + verifier = _verifier; + emit VerifierUpdated(_verifier); + } + + function getMessageHash( + address _to, + address _nft, + NFTInfo[] memory _ids, + address _contract, + uint256 _chainId, + uint256 _signTime, + uint256 _saltNonce + ) public pure returns (bytes32) { + bytes memory encoded = abi.encodePacked(_to, _nft, _contract, _chainId, _signTime, _saltNonce); + for (uint256 i = 0; i < _ids.length; ++i) { + encoded = bytes.concat(encoded, abi.encodePacked(_ids[i].tokenId)); + encoded = bytes.concat(encoded, abi.encodePacked(_ids[i].to)); + encoded = bytes.concat(encoded, abi.encodePacked(_ids[i].isMint)); + } + return keccak256(encoded); + } +} diff --git a/deploy/4_2_deploy_nftlocker_v2.ts b/deploy/4_2_deploy_nftlocker_v2.ts new file mode 100644 index 0000000..4423df4 --- /dev/null +++ b/deploy/4_2_deploy_nftlocker_v2.ts @@ -0,0 +1,29 @@ +import { HardhatRuntimeEnvironment } from "hardhat/types"; +import { DeployFunction } from "hardhat-deploy/types"; +import { updateArray } from "../scripts/utils" + + +const deployNFTClaim: DeployFunction = + async function (hre: HardhatRuntimeEnvironment) { + const provider = hre.ethers.provider; + const from = await (await provider.getSigner()).getAddress(); + const config = require(`../config/config_${hre.network.name}`); + const verifier = config.admins.admin + const ret = await hre.deployments.deploy("NFTLockV2", { + from, + args: [3600, verifier], + log: true, + }); + console.log("==NFTLock addr=", ret.address); + updateArray({ + name: "NFTLock", + type: "logic", + json: "assets/contracts/NFTLock.json", + address: ret.address, + network: hre.network.name, + }); + }; + + deployNFTClaim.tags = ["NFTLockV2"]; + +export default deployNFTClaim; diff --git a/package.json b/package.json index e1fe628..0081d1c 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "deploy:nftclaimwl": "hardhat deploy --tags NFTClaimStage2WL --network imtbl_test --reset", "deploy:nft": "hardhat deploy --tags CFNFTGame --network imtbl_test --reset", "deploy:nftlock": "hardhat deploy --tags NFTLock --network imtbl_test --reset", + "deploy:nftlock:v2": "hardhat deploy --tags NFTLockV2 --network imtbl_test --reset", "deploy:nftlock:main": "hardhat deploy --tags NFTLockMain --network sepolia_test --reset", "deploy:testtoken": "hardhat deploy --tags TestToken --network imtbl_test --reset", "deploy:airdrop": "hardhat deploy --tags AirdropToken --network imtbl_test --reset", diff --git a/test/testNFTLockerV2.ts b/test/testNFTLockerV2.ts new file mode 100644 index 0000000..b0b563f --- /dev/null +++ b/test/testNFTLockerV2.ts @@ -0,0 +1,128 @@ +import { expect } from 'chai' +import hre from "hardhat"; +import { + getBytes, + solidityPackedKeccak256, +} from 'ethers' +import { + loadFixture, +} from "@nomicfoundation/hardhat-toolbox/network-helpers"; + +describe('NFTLock', function() { + async function deployOneContract() { + const [owner, otherAccount] = await hre.ethers.getSigners(); + const verifier = owner.address; + const OperatorAllowlist = await hre.ethers.getContractFactory("OperatorAllowlist"); + const operatorAllowlist = await OperatorAllowlist.deploy(owner.address); + + const CFNFTGame = await hre.ethers.getContractFactory("CFNFTGame"); + const nft = await CFNFTGame.deploy(owner.address, 'name', 'symbol', 'baseURI', 'contractURI', operatorAllowlist.target, owner.address, 5); + const nftAddress = nft.target; + const NFTLock = await hre.ethers.getContractFactory("NFTLockV2"); + + const nftLock = await NFTLock.deploy( 3600, verifier ); + await nft.grantMinterRole(nftLock.target) + await nft.grantMinterRole(owner.address) + await nftLock.addSupportNftList([nftAddress]); + await nft.mint(otherAccount.address, "1001"); + const chainId = hre.network.config.chainId + await operatorAllowlist.grantRegistrarRole(owner.address) + await operatorAllowlist.addAddressToAllowlist([nftLock.target]) + return { nftLock, owner, otherAccount, verifier, nftAddress, nft, chainId }; + } + describe("Deployment", function () { + it('should deploy NFTLock with the correct verifier', async function() { + const { nftLock, verifier } = await loadFixture(deployOneContract); + expect(await nftLock.verifier()).to.equal(verifier); + }); + it('should deploy NFTLock with the correct NFT address', async function() { + const { nftLock, nftAddress } = await loadFixture(deployOneContract); + expect(await nftLock.supportNftList(nftAddress)).to.equal(true); + }); + }) + + describe("Lock", function () { + it('should lock NFT', async function() { + const { nftLock, nft, otherAccount, owner } = await loadFixture(deployOneContract); + const tokenId = "1001" + // @ts-ignore + await nft.connect(otherAccount).approve(nftLock.target, tokenId); + //@ts-ignore + await nftLock.connect(otherAccount).lock(nft.target, owner.address, [tokenId]); + expect(await nft.balanceOf(owner.address)).to.equal(0); + }); + it('should revert lock NFT for not owner', async function() { + const { nftLock, nft, otherAccount, owner } = await loadFixture(deployOneContract); + const tokenId = "1001" + // @ts-ignore + await nft.connect(otherAccount).approve(nftLock.target, tokenId); + await expect(nftLock.lock(nft.target, owner.address, [tokenId])).to.be.revertedWith("not owner"); + }); + it('should revert lock NFT for not approved', async function() { + const { nftLock, nft, otherAccount, owner } = await loadFixture(deployOneContract); + const tokenId = "1001" + // @ts-ignore + await expect(nftLock.connect(otherAccount).lock(nft.target, owner.address, [tokenId])).to.be.revertedWith("ERC721: caller is not token owner or approved"); + }); + it('should revert lock NFT for invalid token id', async function() { + const { nftLock, nft, otherAccount, owner } = await loadFixture(deployOneContract); + const tokenId = "1002" + // @ts-ignore + await expect(nft.connect(otherAccount).approve(nftLock.target, tokenId)).to.be.revertedWith("ERC721: invalid token ID"); + }); + }) + + describe("UnLock", function () { + it('should mint NFT from lock', async function() { + const { nftLock, nft, otherAccount, chainId, owner } = await loadFixture(deployOneContract); + const tokenId = '1002' + const isMint = true + + const nonce = (Math.random() * 1000) | 0; + const now = Date.now() / 1000 | 0; + let localMsgHash = solidityPackedKeccak256(["address", "address", "address", "uint256", "uint256", "uint256", "uint256", "address", "bool"], + [otherAccount.address, nft.target, nftLock.target, chainId, now, nonce, tokenId, otherAccount.address, isMint]); + const signature = await owner.signMessage(getBytes(localMsgHash)); + //@ts-ignore + await nftLock.connect(otherAccount).unlockOrMint(nft.target, [[tokenId, otherAccount.address, isMint]], now, nonce, signature); + expect(await nft.ownerOf(tokenId)).to.equal(otherAccount.address); + }); + + it('should revert NFT unlock', async function() { + const { nftLock, nft, otherAccount, chainId, owner } = await loadFixture(deployOneContract); + const tokenId = '1001' + const isMint = false + + const nonce = (Math.random() * 1000) | 0; + const now = Date.now() / 1000 | 0; + let localMsgHash = solidityPackedKeccak256(["address", "address", "address", "uint256", "uint256", "uint256", "uint256", "address", "bool"], + [otherAccount.address, nft.target, nftLock.target, chainId, now, nonce, tokenId, otherAccount.address, isMint]); + const signature = await owner.signMessage(getBytes(localMsgHash)); + //@ts-ignore + await expect(nftLock.connect(otherAccount).unlockOrMint(nft.target, [[tokenId, otherAccount.address, isMint]], now, nonce, signature)).to.be.revertedWith( + "mint only" + ); + }); + it('should revert NFT unlock for alreay burned', async function() { + const { nftLock, nft, otherAccount, chainId, owner } = await loadFixture(deployOneContract); + const tokenId = '1001' + // @ts-ignore + await nft.connect(otherAccount).approve(nftLock.target, tokenId); + //@ts-ignore + await nftLock.connect(otherAccount).lock(nft.target, owner.address, [tokenId]); + + const isMint = true + + const nonce = (Math.random() * 1000) | 0; + const now = Date.now() / 1000 | 0; + let localMsgHash = solidityPackedKeccak256(["address", "address", "address", "uint256", "uint256", "uint256", "uint256", "address", "bool"], + [otherAccount.address, nft.target, nftLock.target, chainId, now, nonce, tokenId, otherAccount.address, isMint]); + const signature = await owner.signMessage(getBytes(localMsgHash)); + //@ts-ignore + await expect(nftLock.connect(otherAccount).unlockOrMint(nft.target, [[tokenId, otherAccount.address, isMint]], now, nonce, signature)).to.be.revertedWithCustomError( + nft, + "IImmutableERC721TokenAlreadyBurned" + ).withArgs(tokenId); + }); + }); +})