diff --git a/.openzeppelin/sepolia.json b/.openzeppelin/sepolia.json index 9b7a1a2..669ab28 100644 --- a/.openzeppelin/sepolia.json +++ b/.openzeppelin/sepolia.json @@ -205,6 +205,205 @@ }, "namespaces": {} } + }, + "84edc5560991480cc63dffc81724b4488934843b6a74795c47dbcbdcbf9ef004": { + "address": "0x3aBded0A50f4Ad3c2f32CF58Ebaebf7b60EAD2B6", + "txHash": "0xeeb6a6b011dd6876e0272c271bbd2e2c45667b57aaa8fe4f50f34727ac148bf4", + "layout": { + "solcVersion": "0.8.19", + "storage": [ + { + "label": "_initialized", + "offset": 0, + "slot": "0", + "type": "t_uint8", + "contract": "Initializable", + "src": "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol:63", + "retypedFrom": "bool" + }, + { + "label": "_initializing", + "offset": 1, + "slot": "0", + "type": "t_bool", + "contract": "Initializable", + "src": "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol:68" + }, + { + "label": "__gap", + "offset": 0, + "slot": "1", + "type": "t_array(t_uint256)50_storage", + "contract": "ERC1967UpgradeUpgradeable", + "src": "@openzeppelin/contracts-upgradeable/proxy/ERC1967/ERC1967UpgradeUpgradeable.sol:169" + }, + { + "label": "__gap", + "offset": 0, + "slot": "51", + "type": "t_array(t_uint256)50_storage", + "contract": "UUPSUpgradeable", + "src": "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol:111" + }, + { + "label": "__gap", + "offset": 0, + "slot": "101", + "type": "t_array(t_uint256)50_storage", + "contract": "ContextUpgradeable", + "src": "@openzeppelin/contracts-upgradeable/utils/ContextUpgradeable.sol:40" + }, + { + "label": "_owner", + "offset": 0, + "slot": "151", + "type": "t_address", + "contract": "OwnableUpgradeable", + "src": "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol:22" + }, + { + "label": "__gap", + "offset": 0, + "slot": "152", + "type": "t_array(t_uint256)49_storage", + "contract": "OwnableUpgradeable", + "src": "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol:94" + }, + { + "label": "treasury", + "offset": 0, + "slot": "201", + "type": "t_address_payable", + "contract": "TreasuryManager", + "src": "contracts/utils/TreasuryManager.sol:10" + }, + { + "label": "fee", + "offset": 0, + "slot": "202", + "type": "t_uint256", + "contract": "TreasuryManager", + "src": "contracts/utils/TreasuryManager.sol:12" + }, + { + "label": "__gap", + "offset": 0, + "slot": "203", + "type": "t_array(t_uint256)48_storage", + "contract": "TreasuryManager", + "src": "contracts/utils/TreasuryManager.sol:15" + }, + { + "label": "validSigner", + "offset": 0, + "slot": "251", + "type": "t_address", + "contract": "GuildRewardNFTFactory", + "src": "contracts/GuildRewardNFTFactory.sol:21" + }, + { + "label": "nftImplementations", + "offset": 0, + "slot": "252", + "type": "t_mapping(t_enum(ContractType)7072,t_address)", + "contract": "GuildRewardNFTFactory", + "src": "contracts/GuildRewardNFTFactory.sol:23" + }, + { + "label": "deployedTokenContracts", + "offset": 0, + "slot": "253", + "type": "t_mapping(t_address,t_array(t_struct(Deployment)7095_storage)dyn_storage)", + "contract": "GuildRewardNFTFactory", + "src": "contracts/GuildRewardNFTFactory.sol:24" + }, + { + "label": "__gap", + "offset": 0, + "slot": "254", + "type": "t_array(t_uint256)47_storage", + "contract": "GuildRewardNFTFactory", + "src": "contracts/GuildRewardNFTFactory.sol:27" + } + ], + "types": { + "t_address": { + "label": "address", + "numberOfBytes": "20" + }, + "t_address_payable": { + "label": "address payable", + "numberOfBytes": "20" + }, + "t_array(t_struct(Deployment)7095_storage)dyn_storage": { + "label": "struct IGuildRewardNFTFactory.Deployment[]", + "numberOfBytes": "32" + }, + "t_array(t_uint256)47_storage": { + "label": "uint256[47]", + "numberOfBytes": "1504" + }, + "t_array(t_uint256)48_storage": { + "label": "uint256[48]", + "numberOfBytes": "1536" + }, + "t_array(t_uint256)49_storage": { + "label": "uint256[49]", + "numberOfBytes": "1568" + }, + "t_array(t_uint256)50_storage": { + "label": "uint256[50]", + "numberOfBytes": "1600" + }, + "t_bool": { + "label": "bool", + "numberOfBytes": "1" + }, + "t_enum(ContractType)7072": { + "label": "enum IGuildRewardNFTFactory.ContractType", + "members": [ + "BASIC_NFT", + "CONFIGURABLE_NFT" + ], + "numberOfBytes": "1" + }, + "t_mapping(t_address,t_array(t_struct(Deployment)7095_storage)dyn_storage)": { + "label": "mapping(address => struct IGuildRewardNFTFactory.Deployment[])", + "numberOfBytes": "32" + }, + "t_mapping(t_enum(ContractType)7072,t_address)": { + "label": "mapping(enum IGuildRewardNFTFactory.ContractType => address)", + "numberOfBytes": "32" + }, + "t_struct(Deployment)7095_storage": { + "label": "struct IGuildRewardNFTFactory.Deployment", + "members": [ + { + "label": "contractAddress", + "type": "t_address", + "offset": 0, + "slot": "0" + }, + { + "label": "contractType", + "type": "t_enum(ContractType)7072", + "offset": 20, + "slot": "0" + } + ], + "numberOfBytes": "32" + }, + "t_uint256": { + "label": "uint256", + "numberOfBytes": "32" + }, + "t_uint8": { + "label": "uint8", + "numberOfBytes": "1" + } + }, + "namespaces": {} + } } } } diff --git a/contracts/BasicGuildRewardNFT.sol b/contracts/BasicGuildRewardNFT.sol index 1445342..cf10c06 100644 --- a/contracts/BasicGuildRewardNFT.sol +++ b/contracts/BasicGuildRewardNFT.sol @@ -20,7 +20,6 @@ contract BasicGuildRewardNFT is TreasuryManager { using ECDSA for bytes32; - using LibTransfer for address; using LibTransfer for address payable; address public factoryProxy; diff --git a/contracts/ConfigurableGuildRewardNFT.sol b/contracts/ConfigurableGuildRewardNFT.sol new file mode 100644 index 0000000..072989d --- /dev/null +++ b/contracts/ConfigurableGuildRewardNFT.sol @@ -0,0 +1,138 @@ +//SPDX-License-Identifier: MIT +pragma solidity 0.8.19; + +import { IConfigurableGuildRewardNFT } from "./interfaces/IConfigurableGuildRewardNFT.sol"; +import { IGuildRewardNFTFactory } from "./interfaces/IGuildRewardNFTFactory.sol"; +import { ITreasuryManager } from "./interfaces/ITreasuryManager.sol"; +import { LibTransfer } from "./lib/LibTransfer.sol"; +import { OptionallySoulboundERC721 } from "./token/OptionallySoulboundERC721.sol"; +import { TreasuryManager } from "./utils/TreasuryManager.sol"; +import { OwnableUpgradeable } from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; +import { Initializable } from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import { ECDSA } from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; + +/// @title An NFT distributed as a reward for Guild.xyz users. +contract ConfigurableGuildRewardNFT is + IConfigurableGuildRewardNFT, + Initializable, + OwnableUpgradeable, + OptionallySoulboundERC721, + TreasuryManager +{ + using ECDSA for bytes32; + using LibTransfer for address payable; + + address public factoryProxy; + uint256 public mintableAmountPerUser; + + /// @notice The cid for tokenURI. + string internal cid; + + /// @notice The number of claimed tokens by userIds. + mapping(uint256 userId => uint256 claimed) internal claimedTokens; + + function initialize( + IGuildRewardNFTFactory.ConfigurableNFTConfig memory nftConfig, + address factoryProxyAddress + ) public initializer { + cid = nftConfig.cid; + mintableAmountPerUser = nftConfig.mintableAmountPerUser; + factoryProxy = factoryProxyAddress; + + __OptionallySoulboundERC721_init(nftConfig.name, nftConfig.symbol, nftConfig.soulbound); + __TreasuryManager_init(nftConfig.treasury, nftConfig.tokenFee); + + _transferOwnership(nftConfig.tokenOwner); + } + + function claim(uint256 amount, address receiver, uint256 userId, bytes calldata signature) external payable { + uint256 mintableAmount = mintableAmountPerUser; + if (amount > mintableAmount - balanceOf(receiver) || amount > mintableAmount - claimedTokens[userId]) + revert AlreadyClaimed(); + if (!isValidSignature(amount, receiver, userId, signature)) revert IncorrectSignature(); + + (uint256 guildFee, address payable guildTreasury) = ITreasuryManager(factoryProxy).getFeeData(); + + claimedTokens[userId] += amount; + + uint256 firstTokenId = totalSupply(); + uint256 lastTokenId = firstTokenId + amount - 1; + + for (uint256 tokenId = firstTokenId; tokenId <= lastTokenId; ) { + _safeMint(receiver, tokenId); + + if (soulbound) emit Locked(tokenId); + else emit Unlocked(tokenId); + + emit Claimed(receiver, tokenId); + + unchecked { + ++tokenId; + } + } + + // Fee collection + uint256 guildAmount = amount * guildFee; + uint256 ownerAmount = amount * fee; + if (msg.value == guildAmount + ownerAmount) { + guildTreasury.sendEther(guildAmount); + treasury.sendEther(ownerAmount); + } else revert IncorrectFee(msg.value, guildAmount + ownerAmount); + } + + function burn(uint256[] calldata tokenIds, uint256 userId, bytes calldata signature) external { + uint256 amount = tokenIds.length; + if (!isValidSignature(amount, msg.sender, userId, signature)) revert IncorrectSignature(); + + for (uint256 i; i < amount; ) { + uint256 tokenId = tokenIds[i]; + if (msg.sender != ownerOf(tokenId)) revert IncorrectSender(); + _burn(tokenId); + + unchecked { + ++i; + } + } + + claimedTokens[userId] -= amount; + } + + function setLocked(bool newLocked) external onlyOwner { + soulbound = newLocked; + if (newLocked) emit Locked(0); + else emit Unlocked(0); + } + + function setMintableAmountPerUser(uint256 newAmount) external onlyOwner { + mintableAmountPerUser = newAmount; + emit MintableAmountPerUserChanged(newAmount); + } + + function updateTokenURI(string calldata newCid) external onlyOwner { + cid = newCid; + emit MetadataUpdate(); + } + + function balanceOf(uint256 userId) external view returns (uint256 amount) { + return claimedTokens[userId]; + } + + function tokenURI(uint256 tokenId) public view override returns (string memory) { + if (!_exists(tokenId)) revert NonExistentToken(tokenId); + + return string.concat("ipfs://", cid); + } + + /// @notice Checks the validity of the signature for the given params. + function isValidSignature( + uint256 amount, + address receiver, + uint256 userId, + bytes calldata signature + ) internal view returns (bool) { + if (signature.length != 65) revert IncorrectSignature(); + bytes32 message = keccak256(abi.encode(amount, receiver, userId, block.chainid, address(this))) + .toEthSignedMessageHash(); + return message.recover(signature) == IGuildRewardNFTFactory(factoryProxy).validSigner(); + } +} diff --git a/contracts/GuildRewardNFTFactory.sol b/contracts/GuildRewardNFTFactory.sol index 8a6410b..f6289b6 100644 --- a/contracts/GuildRewardNFTFactory.sol +++ b/contracts/GuildRewardNFTFactory.sol @@ -2,6 +2,7 @@ pragma solidity 0.8.19; import { IBasicGuildRewardNFT } from "./interfaces/IBasicGuildRewardNFT.sol"; +import { IConfigurableGuildRewardNFT } from "./interfaces/IConfigurableGuildRewardNFT.sol"; import { IGuildRewardNFTFactory } from "./interfaces/IGuildRewardNFTFactory.sol"; import { TreasuryManager } from "./utils/TreasuryManager.sol"; import { OwnableUpgradeable } from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; @@ -39,16 +40,31 @@ contract GuildRewardNFTFactory is address payable tokenTreasury, uint256 tokenFee ) external { - address deployedCloneAddress = ClonesUpgradeable.clone(nftImplementations[ContractType.BASIC_NFT]); + ContractType contractType = ContractType.BASIC_NFT; + address deployedCloneAddress = ClonesUpgradeable.clone(nftImplementations[contractType]); IBasicGuildRewardNFT deployedClone = IBasicGuildRewardNFT(deployedCloneAddress); deployedClone.initialize(name, symbol, cid, tokenOwner, tokenTreasury, tokenFee, address(this)); deployedTokenContracts[msg.sender].push( - Deployment({ contractAddress: deployedCloneAddress, contractType: ContractType.BASIC_NFT }) + Deployment({ contractAddress: deployedCloneAddress, contractType: contractType }) ); - emit RewardNFTDeployed(msg.sender, deployedCloneAddress); + emit RewardNFTDeployed(msg.sender, deployedCloneAddress, contractType); + } + + function deployConfigurableNFT(ConfigurableNFTConfig memory nftConfig) external { + ContractType contractType = ContractType.CONFIGURABLE_NFT; + address deployedCloneAddress = ClonesUpgradeable.clone(nftImplementations[contractType]); + IConfigurableGuildRewardNFT deployedClone = IConfigurableGuildRewardNFT(deployedCloneAddress); + + deployedClone.initialize(nftConfig, address(this)); + + deployedTokenContracts[msg.sender].push( + Deployment({ contractAddress: deployedCloneAddress, contractType: contractType }) + ); + + emit RewardNFTDeployed(msg.sender, deployedCloneAddress, contractType); } function setNFTImplementation(ContractType contractType, address newNFT) external onlyOwner { diff --git a/contracts/interfaces/IBasicGuildRewardNFT.sol b/contracts/interfaces/IBasicGuildRewardNFT.sol index fd6e076..20127fb 100644 --- a/contracts/interfaces/IBasicGuildRewardNFT.sol +++ b/contracts/interfaces/IBasicGuildRewardNFT.sol @@ -13,8 +13,8 @@ interface IBasicGuildRewardNFT { /// @return claimed Whether the address has claimed their token. function hasClaimed(address account) external view returns (bool claimed); - /// @notice Whether a userId has minted a token. - /// @dev Used to prevent double mints in the same block. + /// @notice Whether a userId has claimed a token. + /// @dev Used to prevent double claims in the same block. /// @param userId The id of the user on Guild. /// @return claimed Whether the userId has claimed any tokens. function hasTheUserIdClaimed(uint256 userId) external view returns (bool claimed); @@ -23,10 +23,10 @@ interface IBasicGuildRewardNFT { /// @dev Initializer function callable only once. /// @param name The name of the token. /// @param symbol The symbol of the token. - /// @param cid The cid used to construct the tokenURI for the token to be minted. - /// @param tokenOwner The address that will be the owner of the deployed token. + /// @param cid The cid used to construct the tokenURI for the token to be deployed. + /// @param tokenOwner The address that will be the owner of the token. /// @param treasury The address that will receive the price paid for the token. - /// @param tokenFee The price of every mint in wei. + /// @param tokenFee The price of every claim in wei. /// @param factoryProxyAddress The address of the factory. function initialize( string memory name, @@ -68,7 +68,7 @@ interface IBasicGuildRewardNFT { /// @notice Error thrown when an incorrect amount of fee is attempted to be paid. /// @param paid The amount of funds received. - /// @param requiredAmount The amount of fees required for minting. + /// @param requiredAmount The amount of fees required for claiming. error IncorrectFee(uint256 paid, uint256 requiredAmount); /// @notice Error thrown when the sender is not permitted to do a specific action. diff --git a/contracts/interfaces/IConfigurableGuildRewardNFT.sol b/contracts/interfaces/IConfigurableGuildRewardNFT.sol new file mode 100644 index 0000000..b6da4c2 --- /dev/null +++ b/contracts/interfaces/IConfigurableGuildRewardNFT.sol @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import { IGuildRewardNFTFactory } from "./IGuildRewardNFTFactory.sol"; + +/// @title An NFT distributed as a reward for Guild.xyz users. +interface IConfigurableGuildRewardNFT { + /// @notice The address of the proxy to be used when interacting with the factory. + /// @dev Used to access the factory's address when interacting through minimal proxies. + /// @return factoryAddress The address of the factory. + function factoryProxy() external view returns (address factoryAddress); + + /// @notice The maximum amount of tokens a Guild user can claim from the token. + /// @dev Doesn't matter if they are claimed in the same transaction or separately. + /// @return mintableAmountPerUser The amount of tokens. + function mintableAmountPerUser() external view returns (uint256 mintableAmountPerUser); + + /// @notice Returns the number of tokens the user claimed. + /// @dev Analogous to balanceOf(address), but works with Guild user ids. + /// @param userId The id of the user on Guild. + /// @return amount The number of tokens the userId has claimed. + function balanceOf(uint256 userId) external view returns (uint256 amount); + + /// @notice Sets metadata and the associated addresses. + /// @dev Initializer function callable only once. + /// @param nftConfig See struct ConfigurableNFTConfig in IGuildRewardNFTFactory. + /// @param factoryProxyAddress The address of the factory. + function initialize( + IGuildRewardNFTFactory.ConfigurableNFTConfig memory nftConfig, + address factoryProxyAddress + ) external; + + /// @notice Claims tokens to the given address. + /// @param amount The amount of tokens to mint. Should be less or equal to mintableAmountPerUser. + /// @param receiver The address that receives the token. + /// @param userId The id of the user on Guild. + /// @param signature The following signed by validSigner: amount, receiver, userId, chainId, the contract's address. + function claim(uint256 amount, address receiver, uint256 userId, bytes calldata signature) external payable; + + /// @notice Burns tokens from the sender. + /// @param tokenIds The tokenIds to burn. All of them should belong to userId. + /// @param userId The id of the user on Guild. + /// @param signature The following signed by validSigner: amount, receiver, userId, chainId, the contract's address. + function burn(uint256[] calldata tokenIds, uint256 userId, bytes calldata signature) external; + + /// @notice Sets the locked (i.e. soulboundness) status of all of the tokens in this NFT. + /// @dev Only callable by the owner. + /// @param newLocked Whether the token should be soulbound or not. + function setLocked(bool newLocked) external; + + /// @notice Sets the amount of tokens a user can mint from the token. + /// @dev Only callable by the owner. + /// @param newAmount The new amount a user can mint from the token. + function setMintableAmountPerUser(uint256 newAmount) external; + + /// @notice Updates the cid for tokenURI. + /// @dev Only callable by the owner. + /// @param newCid The new cid that points to the updated image. + function updateTokenURI(string calldata newCid) external; + + /// @notice Event emitted whenever a claim succeeds. + /// @param receiver The address that received the tokens. + /// @param tokenId The id of the token. + event Claimed(address indexed receiver, uint256 tokenId); + + /// @notice Event emitted whenever the cid is updated. + event MetadataUpdate(); + + /// @notice Event emitted when the mintableAmountPerUser is changed. + /// @param newAmount The new amount a user can mint from the token. + event MintableAmountPerUserChanged(uint256 newAmount); + + /// @notice Error thrown when the token is already claimed. + error AlreadyClaimed(); + + /// @notice Error thrown when an incorrect amount of fee is attempted to be paid. + /// @param paid The amount of funds received. + /// @param requiredAmount The amount of fees required for claiming a single token. + error IncorrectFee(uint256 paid, uint256 requiredAmount); + + /// @notice Error thrown when the sender is not permitted to do a specific action. + error IncorrectSender(); + + /// @notice Error thrown when the supplied signature is invalid. + error IncorrectSignature(); +} diff --git a/contracts/interfaces/IGuildRewardNFTFactory.sol b/contracts/interfaces/IGuildRewardNFTFactory.sol index 58e7fce..5dc82e2 100644 --- a/contracts/interfaces/IGuildRewardNFTFactory.sol +++ b/contracts/interfaces/IGuildRewardNFTFactory.sol @@ -6,7 +6,29 @@ interface IGuildRewardNFTFactory { /// @notice The type of the contract. /// @dev Used as an identifier. Should be expanded in future updates. enum ContractType { - BASIC_NFT + BASIC_NFT, + CONFIGURABLE_NFT + } + + /// @notice Input parameters of the deployConfigurableNFT function. + /// @dev Needed to prevent "stack too deep" errors. + /// @param name The name of the NFT to be created. + /// @param symbol The symbol of the NFT to be created. + /// @param cid The cid used to construct the tokenURI of the NFT to be created. + /// @param tokenOwner The address that will be the owner of the deployed token. + /// @param tokenTreasury The address that will collect the prices of the minted tokens. + /// @param tokenFee The price of every mint in wei. + /// @param soulbound Whether the token should be soulbound. + /// @param mintableAmountPerUser The maximum amount a user will be able to mint from the deployed token. + struct ConfigurableNFTConfig { + string name; + string symbol; + string cid; + address tokenOwner; + address payable treasury; + uint256 tokenFee; + bool soulbound; + uint256 mintableAmountPerUser; } /// @notice Information about a specific deployment. @@ -37,7 +59,7 @@ interface IGuildRewardNFTFactory { /// @param symbol The symbol of the NFT to be created. /// @param cid The cid used to construct the tokenURI of the NFT to be created. /// @param tokenOwner The address that will be the owner of the deployed token. - /// @param tokenTreasury The address that will collect the prices of the minted deployed tokens. + /// @param tokenTreasury The address that will collect the prices of the minted tokens. /// @param tokenFee The price of every mint in wei. function deployBasicNFT( string calldata name, @@ -48,6 +70,10 @@ interface IGuildRewardNFTFactory { uint256 tokenFee ) external; + /// @notice Deploys a minimal proxy for a configurable NFT. + /// @param nftConfig The config to initialize the token to be deployed with. + function deployConfigurableNFT(ConfigurableNFTConfig memory nftConfig) external; + /// @notice Returns the reward NFT addresses for a guild. /// @param deployer The address that deployed the tokens. /// @return tokens The addresses of the tokens deployed by deployer. @@ -72,7 +98,8 @@ interface IGuildRewardNFTFactory { /// @notice Event emitted when a new NFT is deployed. /// @param deployer The address that deployed the token. /// @param tokenAddress The address of the token. - event RewardNFTDeployed(address deployer, address tokenAddress); + /// @param contractType The type of the NFT deployed. + event RewardNFTDeployed(address deployer, address tokenAddress, ContractType contractType); /// @notice Event emitted when the validSigner is changed. /// @param newValidSigner The new address of validSigner. diff --git a/contracts/token/OptionallySoulboundERC721.sol b/contracts/token/OptionallySoulboundERC721.sol new file mode 100644 index 0000000..52cf391 --- /dev/null +++ b/contracts/token/OptionallySoulboundERC721.sol @@ -0,0 +1,116 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.19; + +/* solhint-disable max-line-length */ + +import { IERC5192 } from "../interfaces/IERC5192.sol"; +import { ERC721Upgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC721/ERC721Upgradeable.sol"; +import { IERC721Upgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC721/IERC721Upgradeable.sol"; +import { ERC721EnumerableUpgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC721/extensions/ERC721EnumerableUpgradeable.sol"; +import { IERC721EnumerableUpgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC721/extensions/IERC721EnumerableUpgradeable.sol"; + +/* solhint-enable max-line-length */ + +/// @title An enumerable ERC721 that's optionally soulbound. +/// @notice Allowance and transfer-related functions are disabled in soulbound mode. +/// @dev Inheriting from upgradeable contracts here - even though we're using it in a non-upgradeable way, +/// we still want it to be initializable +contract OptionallySoulboundERC721 is ERC721Upgradeable, ERC721EnumerableUpgradeable, IERC5192 { + /// @notice Whether the token is set as soulbound. + bool internal soulbound; + + /// @notice Error thrown when trying to query info about a token that's not (yet) minted. + /// @param tokenId The queried id. + error NonExistentToken(uint256 tokenId); + + /// @notice Error thrown when a function's execution is not possible, because the soulbound mode is on. + error Soulbound(); + + /// @notice Reverts the function execution if the token is soulbound. + modifier checkSoulbound() { + if (soulbound) revert Soulbound(); + _; + } + + // solhint-disable-next-line func-name-mixedcase + function __OptionallySoulboundERC721_init( + string memory name_, + string memory symbol_, + bool soulbound_ + ) internal onlyInitializing { + soulbound = soulbound_; + __ERC721_init(name_, symbol_); + __ERC721Enumerable_init(); + } + + /// @inheritdoc ERC721EnumerableUpgradeable + function supportsInterface( + bytes4 interfaceId + ) public view virtual override(ERC721EnumerableUpgradeable, ERC721Upgradeable) returns (bool) { + return + interfaceId == type(IERC5192).interfaceId || + interfaceId == type(IERC721EnumerableUpgradeable).interfaceId || + super.supportsInterface(interfaceId); + } + + function locked(uint256 tokenId) external view returns (bool) { + if (!_exists(tokenId)) revert NonExistentToken(tokenId); + return soulbound; + } + + function approve( + address to, + uint256 tokenId + ) public virtual override(IERC721Upgradeable, ERC721Upgradeable) checkSoulbound { + super.approve(to, tokenId); + } + + function setApprovalForAll( + address operator, + bool approved + ) public virtual override(IERC721Upgradeable, ERC721Upgradeable) checkSoulbound { + super.setApprovalForAll(operator, approved); + } + + function isApprovedForAll( + address owner, + address operator + ) public view virtual override(IERC721Upgradeable, ERC721Upgradeable) checkSoulbound returns (bool) { + return super.isApprovedForAll(owner, operator); + } + + function transferFrom( + address from, + address to, + uint256 tokenId + ) public virtual override(IERC721Upgradeable, ERC721Upgradeable) checkSoulbound { + super.transferFrom(from, to, tokenId); + } + + function safeTransferFrom( + address from, + address to, + uint256 tokenId + ) public virtual override(IERC721Upgradeable, ERC721Upgradeable) checkSoulbound { + super.safeTransferFrom(from, to, tokenId); + } + + function safeTransferFrom( + address from, + address to, + uint256 tokenId, + bytes memory data + ) public virtual override(IERC721Upgradeable, ERC721Upgradeable) checkSoulbound { + super.safeTransferFrom(from, to, tokenId, data); + } + + /// @dev Used for minting/burning even when soulbound. + function _beforeTokenTransfer( + address from, + address to, + uint256 firstTokenId, + uint256 batchSize + ) internal virtual override(ERC721EnumerableUpgradeable, ERC721Upgradeable) { + super._beforeTokenTransfer(from, to, firstTokenId, batchSize); + } +} diff --git a/docs/contracts/BasicGuildRewardNFT.md b/docs/contracts/BasicGuildRewardNFT.md index 22f1f6d..dd9d86f 100644 --- a/docs/contracts/BasicGuildRewardNFT.md +++ b/docs/contracts/BasicGuildRewardNFT.md @@ -150,9 +150,9 @@ function hasTheUserIdClaimed( ) external returns (bool claimed) ``` -Whether a userId has minted a token. +Whether a userId has claimed a token. -Used to prevent double mints in the same block. +Used to prevent double claims in the same block. #### Parameters diff --git a/docs/contracts/ConfigurableGuildRewardNFT.md b/docs/contracts/ConfigurableGuildRewardNFT.md new file mode 100644 index 0000000..c9d7d25 --- /dev/null +++ b/docs/contracts/ConfigurableGuildRewardNFT.md @@ -0,0 +1,231 @@ +# ConfigurableGuildRewardNFT + +An NFT distributed as a reward for Guild.xyz users. + +## Variables + +### factoryProxy + +```solidity +address factoryProxy +``` + +The address of the proxy to be used when interacting with the factory. + +_Used to access the factory's address when interacting through minimal proxies._ + +#### Return Values + +| Name | Type | Description | +| ---- | ---- | ----------- | + +### mintableAmountPerUser + +```solidity +uint256 mintableAmountPerUser +``` + +The maximum amount of tokens a Guild user can claim from the token. + +_Doesn't matter if they are claimed in the same transaction or separately._ + +#### Return Values + +| Name | Type | Description | +| ---- | ---- | ----------- | + +### cid + +```solidity +string cid +``` + +The cid for tokenURI. + +### claimedTokens + +```solidity +mapping(uint256 => uint256) claimedTokens +``` + +The number of claimed tokens by userIds. + +## Functions + +### initialize + +```solidity +function initialize( + struct IGuildRewardNFTFactory.ConfigurableNFTConfig nftConfig, + address factoryProxyAddress +) public +``` + +Sets metadata and the associated addresses. + +Initializer function callable only once. + +#### Parameters + +| Name | Type | Description | +| :--- | :--- | :---------- | +| `nftConfig` | struct IGuildRewardNFTFactory.ConfigurableNFTConfig | See struct ConfigurableNFTConfig in IGuildRewardNFTFactory. | +| `factoryProxyAddress` | address | The address of the factory. | + +### claim + +```solidity +function claim( + uint256 amount, + address receiver, + uint256 userId, + bytes signature +) external +``` + +Claims tokens to the given address. + +#### Parameters + +| Name | Type | Description | +| :--- | :--- | :---------- | +| `amount` | uint256 | The amount of tokens to mint. Should be less or equal to mintableAmountPerUser. | +| `receiver` | address | The address that receives the token. | +| `userId` | uint256 | The id of the user on Guild. | +| `signature` | bytes | The following signed by validSigner: amount, receiver, userId, chainId, the contract's address. | + +### burn + +```solidity +function burn( + uint256[] tokenIds, + uint256 userId, + bytes signature +) external +``` + +Burns tokens from the sender. + +#### Parameters + +| Name | Type | Description | +| :--- | :--- | :---------- | +| `tokenIds` | uint256[] | The tokenIds to burn. All of them should belong to userId. | +| `userId` | uint256 | The id of the user on Guild. | +| `signature` | bytes | The following signed by validSigner: amount, receiver, userId, chainId, the contract's address. | + +### setLocked + +```solidity +function setLocked( + bool newLocked +) external +``` + +Sets the locked (i.e. soulboundness) status of all of the tokens in this NFT. + +Only callable by the owner. + +#### Parameters + +| Name | Type | Description | +| :--- | :--- | :---------- | +| `newLocked` | bool | Whether the token should be soulbound or not. | + +### setMintableAmountPerUser + +```solidity +function setMintableAmountPerUser( + uint256 newAmount +) external +``` + +Sets the amount of tokens a user can mint from the token. + +Only callable by the owner. + +#### Parameters + +| Name | Type | Description | +| :--- | :--- | :---------- | +| `newAmount` | uint256 | The new amount a user can mint from the token. | + +### updateTokenURI + +```solidity +function updateTokenURI( + string newCid +) external +``` + +Updates the cid for tokenURI. + +Only callable by the owner. + +#### Parameters + +| Name | Type | Description | +| :--- | :--- | :---------- | +| `newCid` | string | The new cid that points to the updated image. | + +### balanceOf + +```solidity +function balanceOf( + uint256 userId +) external returns (uint256 amount) +``` + +Returns the number of tokens the user claimed. + +Analogous to balanceOf(address), but works with Guild user ids. + +#### Parameters + +| Name | Type | Description | +| :--- | :--- | :---------- | +| `userId` | uint256 | The id of the user on Guild. | + +#### Return Values + +| Name | Type | Description | +| :--- | :--- | :---------- | +| `amount` | uint256 | The number of tokens the userId has claimed. | +### tokenURI + +```solidity +function tokenURI( + uint256 tokenId +) public returns (string) +``` + +See {IERC721Metadata-tokenURI}. + +#### Parameters + +| Name | Type | Description | +| :--- | :--- | :---------- | +| `tokenId` | uint256 | | + +### isValidSignature + +```solidity +function isValidSignature( + uint256 amount, + address receiver, + uint256 userId, + bytes signature +) internal returns (bool) +``` + +Checks the validity of the signature for the given params. + +#### Parameters + +| Name | Type | Description | +| :--- | :--- | :---------- | +| `amount` | uint256 | | +| `receiver` | address | | +| `userId` | uint256 | | +| `signature` | bytes | | + diff --git a/docs/contracts/GuildRewardNFTFactory.md b/docs/contracts/GuildRewardNFTFactory.md index d8ba5b2..c9803f7 100644 --- a/docs/contracts/GuildRewardNFTFactory.md +++ b/docs/contracts/GuildRewardNFTFactory.md @@ -86,9 +86,25 @@ Deploys a minimal proxy for a basic NFT. | `symbol` | string | The symbol of the NFT to be created. | | `cid` | string | The cid used to construct the tokenURI of the NFT to be created. | | `tokenOwner` | address | The address that will be the owner of the deployed token. | -| `tokenTreasury` | address payable | The address that will collect the prices of the minted deployed tokens. | +| `tokenTreasury` | address payable | The address that will collect the prices of the minted tokens. | | `tokenFee` | uint256 | The price of every mint in wei. | +### deployConfigurableNFT + +```solidity +function deployConfigurableNFT( + struct IGuildRewardNFTFactory.ConfigurableNFTConfig nftConfig +) external +``` + +Deploys a minimal proxy for a configurable NFT. + +#### Parameters + +| Name | Type | Description | +| :--- | :--- | :---------- | +| `nftConfig` | struct IGuildRewardNFTFactory.ConfigurableNFTConfig | The config to initialize the token to be deployed with. | + ### setNFTImplementation ```solidity diff --git a/docs/contracts/interfaces/IBasicGuildRewardNFT.md b/docs/contracts/interfaces/IBasicGuildRewardNFT.md index c366281..6002f15 100644 --- a/docs/contracts/interfaces/IBasicGuildRewardNFT.md +++ b/docs/contracts/interfaces/IBasicGuildRewardNFT.md @@ -48,9 +48,9 @@ function hasTheUserIdClaimed( ) external returns (bool claimed) ``` -Whether a userId has minted a token. +Whether a userId has claimed a token. -Used to prevent double mints in the same block. +Used to prevent double claims in the same block. #### Parameters @@ -87,10 +87,10 @@ Initializer function callable only once. | :--- | :--- | :---------- | | `name` | string | The name of the token. | | `symbol` | string | The symbol of the token. | -| `cid` | string | The cid used to construct the tokenURI for the token to be minted. | -| `tokenOwner` | address | The address that will be the owner of the deployed token. | +| `cid` | string | The cid used to construct the tokenURI for the token to be deployed. | +| `tokenOwner` | address | The address that will be the owner of the token. | | `treasury` | address payable | The address that will receive the price paid for the token. | -| `tokenFee` | uint256 | The price of every mint in wei. | +| `tokenFee` | uint256 | The price of every claim in wei. | | `factoryProxyAddress` | address | The address of the factory. | ### claim @@ -202,7 +202,7 @@ Error thrown when an incorrect amount of fee is attempted to be paid. | Name | Type | Description | | ---- | ---- | ----------- | | paid | uint256 | The amount of funds received. | -| requiredAmount | uint256 | The amount of fees required for minting. | +| requiredAmount | uint256 | The amount of fees required for claiming. | ### IncorrectSender diff --git a/docs/contracts/interfaces/IConfigurableGuildRewardNFT.md b/docs/contracts/interfaces/IConfigurableGuildRewardNFT.md new file mode 100644 index 0000000..f89d021 --- /dev/null +++ b/docs/contracts/interfaces/IConfigurableGuildRewardNFT.md @@ -0,0 +1,260 @@ +# IConfigurableGuildRewardNFT + +An NFT distributed as a reward for Guild.xyz users. + +## Functions + +### factoryProxy + +```solidity +function factoryProxy() external returns (address factoryAddress) +``` + +The address of the proxy to be used when interacting with the factory. + +Used to access the factory's address when interacting through minimal proxies. + +#### Return Values + +| Name | Type | Description | +| :--- | :--- | :---------- | +| `factoryAddress` | address | The address of the factory. | +### mintableAmountPerUser + +```solidity +function mintableAmountPerUser() external returns (uint256 mintableAmountPerUser) +``` + +The maximum amount of tokens a Guild user can claim from the token. + +Doesn't matter if they are claimed in the same transaction or separately. + +#### Return Values + +| Name | Type | Description | +| :--- | :--- | :---------- | +| `mintableAmountPerUser` | uint256 | The amount of tokens. | +### balanceOf + +```solidity +function balanceOf( + uint256 userId +) external returns (uint256 amount) +``` + +Returns the number of tokens the user claimed. + +Analogous to balanceOf(address), but works with Guild user ids. + +#### Parameters + +| Name | Type | Description | +| :--- | :--- | :---------- | +| `userId` | uint256 | The id of the user on Guild. | + +#### Return Values + +| Name | Type | Description | +| :--- | :--- | :---------- | +| `amount` | uint256 | The number of tokens the userId has claimed. | +### initialize + +```solidity +function initialize( + struct IGuildRewardNFTFactory.ConfigurableNFTConfig nftConfig, + address factoryProxyAddress +) external +``` + +Sets metadata and the associated addresses. + +Initializer function callable only once. + +#### Parameters + +| Name | Type | Description | +| :--- | :--- | :---------- | +| `nftConfig` | struct IGuildRewardNFTFactory.ConfigurableNFTConfig | See struct ConfigurableNFTConfig in IGuildRewardNFTFactory. | +| `factoryProxyAddress` | address | The address of the factory. | + +### claim + +```solidity +function claim( + uint256 amount, + address receiver, + uint256 userId, + bytes signature +) external +``` + +Claims tokens to the given address. + +#### Parameters + +| Name | Type | Description | +| :--- | :--- | :---------- | +| `amount` | uint256 | The amount of tokens to mint. Should be less or equal to mintableAmountPerUser. | +| `receiver` | address | The address that receives the token. | +| `userId` | uint256 | The id of the user on Guild. | +| `signature` | bytes | The following signed by validSigner: amount, receiver, userId, chainId, the contract's address. | + +### burn + +```solidity +function burn( + uint256[] tokenIds, + uint256 userId, + bytes signature +) external +``` + +Burns tokens from the sender. + +#### Parameters + +| Name | Type | Description | +| :--- | :--- | :---------- | +| `tokenIds` | uint256[] | The tokenIds to burn. All of them should belong to userId. | +| `userId` | uint256 | The id of the user on Guild. | +| `signature` | bytes | The following signed by validSigner: amount, receiver, userId, chainId, the contract's address. | + +### setLocked + +```solidity +function setLocked( + bool newLocked +) external +``` + +Sets the locked (i.e. soulboundness) status of all of the tokens in this NFT. + +Only callable by the owner. + +#### Parameters + +| Name | Type | Description | +| :--- | :--- | :---------- | +| `newLocked` | bool | Whether the token should be soulbound or not. | + +### setMintableAmountPerUser + +```solidity +function setMintableAmountPerUser( + uint256 newAmount +) external +``` + +Sets the amount of tokens a user can mint from the token. + +Only callable by the owner. + +#### Parameters + +| Name | Type | Description | +| :--- | :--- | :---------- | +| `newAmount` | uint256 | The new amount a user can mint from the token. | + +### updateTokenURI + +```solidity +function updateTokenURI( + string newCid +) external +``` + +Updates the cid for tokenURI. + +Only callable by the owner. + +#### Parameters + +| Name | Type | Description | +| :--- | :--- | :---------- | +| `newCid` | string | The new cid that points to the updated image. | + +## Events + +### Claimed + +```solidity +event Claimed( + address receiver, + uint256 tokenId +) +``` + +Event emitted whenever a claim succeeds. + +#### Parameters + +| Name | Type | Description | +| :--- | :--- | :---------- | +| `receiver` | address | The address that received the tokens. | +| `tokenId` | uint256 | The id of the token. | +### MetadataUpdate + +```solidity +event MetadataUpdate( +) +``` + +Event emitted whenever the cid is updated. + +### MintableAmountPerUserChanged + +```solidity +event MintableAmountPerUserChanged( + uint256 newAmount +) +``` + +Event emitted when the mintableAmountPerUser is changed. + +#### Parameters + +| Name | Type | Description | +| :--- | :--- | :---------- | +| `newAmount` | uint256 | The new amount a user can mint from the token. | + +## Custom errors + +### AlreadyClaimed + +```solidity +error AlreadyClaimed() +``` + +Error thrown when the token is already claimed. + +### IncorrectFee + +```solidity +error IncorrectFee(uint256 paid, uint256 requiredAmount) +``` + +Error thrown when an incorrect amount of fee is attempted to be paid. + +#### Parameters + +| Name | Type | Description | +| ---- | ---- | ----------- | +| paid | uint256 | The amount of funds received. | +| requiredAmount | uint256 | The amount of fees required for claiming a single token. | + +### IncorrectSender + +```solidity +error IncorrectSender() +``` + +Error thrown when the sender is not permitted to do a specific action. + +### IncorrectSignature + +```solidity +error IncorrectSignature() +``` + +Error thrown when the supplied signature is invalid. + diff --git a/docs/contracts/interfaces/IGuildRewardNFTFactory.md b/docs/contracts/interfaces/IGuildRewardNFTFactory.md index e27f03c..e135687 100644 --- a/docs/contracts/interfaces/IGuildRewardNFTFactory.md +++ b/docs/contracts/interfaces/IGuildRewardNFTFactory.md @@ -81,9 +81,25 @@ Deploys a minimal proxy for a basic NFT. | `symbol` | string | The symbol of the NFT to be created. | | `cid` | string | The cid used to construct the tokenURI of the NFT to be created. | | `tokenOwner` | address | The address that will be the owner of the deployed token. | -| `tokenTreasury` | address payable | The address that will collect the prices of the minted deployed tokens. | +| `tokenTreasury` | address payable | The address that will collect the prices of the minted tokens. | | `tokenFee` | uint256 | The price of every mint in wei. | +### deployConfigurableNFT + +```solidity +function deployConfigurableNFT( + struct IGuildRewardNFTFactory.ConfigurableNFTConfig nftConfig +) external +``` + +Deploys a minimal proxy for a configurable NFT. + +#### Parameters + +| Name | Type | Description | +| :--- | :--- | :---------- | +| `nftConfig` | struct IGuildRewardNFTFactory.ConfigurableNFTConfig | The config to initialize the token to be deployed with. | + ### getDeployedTokenContracts ```solidity @@ -167,7 +183,8 @@ Event emitted when an NFT implementation is changed. ```solidity event RewardNFTDeployed( address deployer, - address tokenAddress + address tokenAddress, + enum IGuildRewardNFTFactory.ContractType contractType ) ``` @@ -179,6 +196,7 @@ Event emitted when a new NFT is deployed. | :--- | :--- | :---------- | | `deployer` | address | The address that deployed the token. | | `tokenAddress` | address | The address of the token. | +| `contractType` | enum IGuildRewardNFTFactory.ContractType | The type of the NFT deployed. | ### ValidSignerChanged ```solidity @@ -201,7 +219,22 @@ Event emitted when the validSigner is changed. ```solidity enum ContractType { - BASIC_NFT + BASIC_NFT, + CONFIGURABLE_NFT +} +``` +### ConfigurableNFTConfig + +```solidity +struct ConfigurableNFTConfig { + string name; + string symbol; + string cid; + address tokenOwner; + address payable treasury; + uint256 tokenFee; + bool soulbound; + uint256 mintableAmountPerUser; } ``` ### Deployment diff --git a/docs/contracts/token/OptionallySoulboundERC721.md b/docs/contracts/token/OptionallySoulboundERC721.md new file mode 100644 index 0000000..84feffa --- /dev/null +++ b/docs/contracts/token/OptionallySoulboundERC721.md @@ -0,0 +1,234 @@ +# OptionallySoulboundERC721 + +An enumerable ERC721 that's optionally soulbound. + +Allowance and transfer-related functions are disabled in soulbound mode. + +Inheriting from upgradeable contracts here - even though we're using it in a non-upgradeable way, +we still want it to be initializable + +## Variables + +### soulbound + +```solidity +bool soulbound +``` + +Whether the token is set as soulbound. + +## Functions + +### __OptionallySoulboundERC721_init + +```solidity +function __OptionallySoulboundERC721_init( + string name_, + string symbol_, + bool soulbound_ +) internal +``` + +#### Parameters + +| Name | Type | Description | +| :--- | :--- | :---------- | +| `name_` | string | | +| `symbol_` | string | | +| `soulbound_` | bool | | + +### supportsInterface + +```solidity +function supportsInterface( + bytes4 interfaceId +) public returns (bool) +``` + +See {IERC165-supportsInterface}. + +#### Parameters + +| Name | Type | Description | +| :--- | :--- | :---------- | +| `interfaceId` | bytes4 | | + +### locked + +```solidity +function locked( + uint256 tokenId +) external returns (bool) +``` + +Returns the locking status of an Soulbound Token + +SBTs assigned to zero address are considered invalid, and queries +about them do throw. + +#### Parameters + +| Name | Type | Description | +| :--- | :--- | :---------- | +| `tokenId` | uint256 | The identifier for an SBT. | + +### approve + +```solidity +function approve( + address to, + uint256 tokenId +) public +``` + +#### Parameters + +| Name | Type | Description | +| :--- | :--- | :---------- | +| `to` | address | | +| `tokenId` | uint256 | | + +### setApprovalForAll + +```solidity +function setApprovalForAll( + address operator, + bool approved +) public +``` + +#### Parameters + +| Name | Type | Description | +| :--- | :--- | :---------- | +| `operator` | address | | +| `approved` | bool | | + +### isApprovedForAll + +```solidity +function isApprovedForAll( + address owner, + address operator +) public returns (bool) +``` + +#### Parameters + +| Name | Type | Description | +| :--- | :--- | :---------- | +| `owner` | address | | +| `operator` | address | | + +### transferFrom + +```solidity +function transferFrom( + address from, + address to, + uint256 tokenId +) public +``` + +#### Parameters + +| Name | Type | Description | +| :--- | :--- | :---------- | +| `from` | address | | +| `to` | address | | +| `tokenId` | uint256 | | + +### safeTransferFrom + +```solidity +function safeTransferFrom( + address from, + address to, + uint256 tokenId +) public +``` + +#### Parameters + +| Name | Type | Description | +| :--- | :--- | :---------- | +| `from` | address | | +| `to` | address | | +| `tokenId` | uint256 | | + +### safeTransferFrom + +```solidity +function safeTransferFrom( + address from, + address to, + uint256 tokenId, + bytes data +) public +``` + +#### Parameters + +| Name | Type | Description | +| :--- | :--- | :---------- | +| `from` | address | | +| `to` | address | | +| `tokenId` | uint256 | | +| `data` | bytes | | + +### _beforeTokenTransfer + +```solidity +function _beforeTokenTransfer( + address from, + address to, + uint256 firstTokenId, + uint256 batchSize +) internal +``` + +Used for minting/burning even when soulbound. + +#### Parameters + +| Name | Type | Description | +| :--- | :--- | :---------- | +| `from` | address | | +| `to` | address | | +| `firstTokenId` | uint256 | | +| `batchSize` | uint256 | | + +## Modifiers + +### checkSoulbound + +```solidity +modifier checkSoulbound() +``` + +Reverts the function execution if the token is soulbound. + +## Custom errors + +### NonExistentToken + +```solidity +error NonExistentToken(uint256 tokenId) +``` + +Error thrown when trying to query info about a token that's not (yet) minted. + +#### Parameters + +| Name | Type | Description | +| ---- | ---- | ----------- | +| tokenId | uint256 | The queried id. | + +### Soulbound + +```solidity +error Soulbound() +``` + +Error thrown when a function's execution is not possible, because the soulbound mode is on. + diff --git a/scripts/deploy-nft-zksync.ts b/scripts/deploy-basic-nft-zksync.ts similarity index 87% rename from scripts/deploy-nft-zksync.ts rename to scripts/deploy-basic-nft-zksync.ts index 38a888c..8a5bd99 100644 --- a/scripts/deploy-nft-zksync.ts +++ b/scripts/deploy-basic-nft-zksync.ts @@ -4,12 +4,12 @@ import * as hre from "hardhat"; import { Wallet } from "zksync-ethers"; // CONFIG -const factoryAddress = "0x..."; /// The address of the proxy to be used when interacting with the factory. +const factoryAddress = "0x..."; // The address of the proxy to be used when interacting with the factory. // Note: the values below are just some defaults. The values in clones will truly matter. const name = ""; // The name of the token. const symbol = ""; // The short, usually all caps symbol of the token. const cid = ""; // The cid that will be returned by the tokenURI. -const tokenOwner = "0x..."; /// The address that will be the owner of the deployed token. +const tokenOwner = "0x..."; // The address that will be the owner of the deployed token. const treasury = "0x..."; // The address that will receive the price paid for mints. const tokenFee = 0; // The price of every mint in wei. diff --git a/scripts/deploy-nft.ts b/scripts/deploy-basic-nft.ts similarity index 86% rename from scripts/deploy-nft.ts rename to scripts/deploy-basic-nft.ts index 403d708..aab8028 100644 --- a/scripts/deploy-nft.ts +++ b/scripts/deploy-basic-nft.ts @@ -1,12 +1,12 @@ import { ethers } from "hardhat"; // CONFIG -const factoryAddress = "0x..."; /// The address of the proxy to be used when interacting with the factory. +const factoryAddress = "0x..."; // The address of the proxy to be used when interacting with the factory. // Note: the values below are just some defaults. The values in clones will truly matter. const name = ""; // The name of the token. const symbol = ""; // The short, usually all caps symbol of the token. const cid = ""; // The cid that will be returned by the tokenURI. -const tokenOwner = "0x..."; /// The address that will be the owner of the deployed token. +const tokenOwner = "0x..."; // The address that will be the owner of the deployed token. const treasury = "0x..."; // The address that will receive the price paid for mints. const tokenFee = 0; // The price of every mint in wei. diff --git a/scripts/deploy-configurable-nft-zksync.ts b/scripts/deploy-configurable-nft-zksync.ts new file mode 100644 index 0000000..0f1832c --- /dev/null +++ b/scripts/deploy-configurable-nft-zksync.ts @@ -0,0 +1,43 @@ +import { Deployer } from "@matterlabs/hardhat-zksync-deploy"; +import "dotenv/config"; +import * as hre from "hardhat"; +import { Wallet } from "zksync-ethers"; + +// CONFIG +const factoryAddress = "0x..."; // The address of the proxy to be used when interacting with the factory. +// Note: the values below are just some defaults. The values in clones will truly matter. +const nftConfig = { + name: "", // The name of the token. + symbol: "", // The short, usually all caps symbol of the token. + cid: "", // The cid that will be returned by the tokenURI. + tokenOwner: "0x...", // The address that will be the owner of the deployed token. + treasury: "0x...", // The address that will receive the price paid for mints. + tokenFee: 0, // The price of every mint in wei. + soulbound: true, // Whether the token should be soulbound or not. + mintableAmountPerUser: 1 // The maximum amount a user will be able to mint from the token. +}; + +async function main() { + const contractName = "ConfigurableGuildRewardNFT"; + + const zkWallet = new Wallet(process.env.PRIVATE_KEY!); + + const deployer = new Deployer(hre, zkWallet); + + const contract = await deployer.loadArtifact(contractName); + const configurableGuildRewardNFT = await deployer.deploy(contract); + + console.log(`Deploying ${contractName} to zkSync...`); + console.log(`Tx hash: ${configurableGuildRewardNFT.deploymentTransaction()?.hash}`); + + await configurableGuildRewardNFT.waitForDeployment(); + + await configurableGuildRewardNFT.initialize(nftConfig, factoryAddress); + + console.log(`${contractName} deployed to:`, await configurableGuildRewardNFT.getAddress()); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/scripts/deploy-configurable-nft.ts b/scripts/deploy-configurable-nft.ts new file mode 100644 index 0000000..57fe13e --- /dev/null +++ b/scripts/deploy-configurable-nft.ts @@ -0,0 +1,37 @@ +import { ethers } from "hardhat"; + +// CONFIG +const factoryAddress = "0x..."; // The address of the proxy to be used when interacting with the factory. +// Note: the values below are just some defaults. The values in clones will truly matter. +const nftConfig = { + name: "", // The name of the token. + symbol: "", // The short, usually all caps symbol of the token. + cid: "", // The cid that will be returned by the tokenURI. + tokenOwner: "0x...", // The address that will be the owner of the deployed token. + treasury: "0x...", // The address that will receive the price paid for mints. + tokenFee: 0, // The price of every mint in wei. + soulbound: true, // Whether the token should be soulbound or not. + mintableAmountPerUser: 1 // The maximum amount a user will be able to mint from the token. +}; + +async function main() { + const contractName = "ConfigurableGuildRewardNFT"; + + const ConfigurableGuildRewardNFT = await ethers.getContractFactory(contractName); + const configurableGuildRewardNFT = await ConfigurableGuildRewardNFT.deploy(); + + const network = await ethers.provider.getNetwork(); + console.log(`Deploying ${contractName} to ${network.name !== "unknown" ? network.name : network.chainId}...`); + console.log(`Tx hash: ${configurableGuildRewardNFT.deploymentTransaction()?.hash}`); + + await configurableGuildRewardNFT.waitForDeployment(); + + await configurableGuildRewardNFT.initialize(nftConfig, factoryAddress); + + console.log(`${contractName} deployed to:`, await configurableGuildRewardNFT.getAddress()); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/scripts/deploy-factory-zksync.ts b/scripts/deploy-factory-zksync.ts index 12c1daa..e8158ec 100644 --- a/scripts/deploy-factory-zksync.ts +++ b/scripts/deploy-factory-zksync.ts @@ -7,7 +7,7 @@ import { Wallet } from "zksync-ethers"; const treasury = "0x..."; // The address where the collected fees will go. const fee = 0; // The Guild base fee for every deployment. const validSigner = "0x..."; // The address that signs the parameters for claiming tokens. -// Note: set NFT implementation after deploying the NFT itself (deploy-nft.ts). +// Note: set NFT implementation after deploying the NFTs. async function main() { const contractName = "GuildRewardNFTFactory"; diff --git a/scripts/deploy-factory.ts b/scripts/deploy-factory.ts index 8681229..83ee0cd 100644 --- a/scripts/deploy-factory.ts +++ b/scripts/deploy-factory.ts @@ -4,7 +4,7 @@ import { ethers, upgrades } from "hardhat"; const treasury = "0x..."; // The address where the collected fees will go. const fee = 0; // The Guild base fee for every deployment. const validSigner = "0x..."; // The address that signs the parameters for claiming tokens. -// Note: set NFT implementation after deploying the NFT itself (deploy-nft.ts). +// Note: set NFT implementation after deploying the NFTs. async function main() { const contractName = "GuildRewardNFTFactory"; diff --git a/scripts/upgrade-factory.ts b/scripts/upgrade-factory.ts index 0637baa..3799326 100644 --- a/scripts/upgrade-factory.ts +++ b/scripts/upgrade-factory.ts @@ -7,8 +7,7 @@ async function main() { const GuildRewardNFTFactory = await ethers.getContractFactory(contractName); const guildRewardNFTFactory = await upgrades.upgradeProxy(factoryAddress, GuildRewardNFTFactory, { - kind: "uups", - unsafeSkipStorageCheck: true + kind: "uups" // call: { fn: "reInitialize", args: [] } }); diff --git a/test/ConfigurableGuildRewardNFT.spec.ts b/test/ConfigurableGuildRewardNFT.spec.ts new file mode 100644 index 0000000..43be9d0 --- /dev/null +++ b/test/ConfigurableGuildRewardNFT.spec.ts @@ -0,0 +1,500 @@ +import { SignerWithAddress } from "@nomicfoundation/hardhat-ethers/signers"; +import { expect } from "chai"; +import { BigNumberish, Contract, ContractFactory } from "ethers"; +import { ethers, upgrades } from "hardhat"; + +// NFT CONFIG (without owner & treasury) +const sampleCids = ["QmPaZD7i8TpLEeGjHtGoXe4mPKbRNNt8YTHH5nrKoqz9wJ", "QmcaGypWsmzaSQQGuExUjtyTRvZ2FF525Ww6PBNWWgkkLj"]; +const baseNFTConfig = { + name: "Guild NFT", + symbol: "GUILDNFT", + cid: sampleCids[0], + tokenFee: ethers.parseEther("0.1"), + soulbound: true, + mintableAmountPerUser: 1n +}; +let nftConfig: typeof baseNFTConfig & { tokenOwner: string; treasury: string }; +const fee = ethers.parseEther("0.15"); +const sampleAmount = 1n; + +// CONTRACTS +let ConfigurableGuildRewardNFT: ContractFactory; +let nft: Contract; +let nftMultipleMints: Contract; +let GuildRewardNFTFactory: ContractFactory; +let factory: Contract; + +// Test accounts +let wallet0: SignerWithAddress; +let randomWallet: SignerWithAddress; +let treasury: SignerWithAddress; +let adminTreasury: SignerWithAddress; +let signer: SignerWithAddress; + +let chainId: BigNumberish; +const sampleUserId = 42; + +enum ContractType { + BASIC_NFT, + CONFIGURABLE_NFT +} + +const createSignature = async ( + wallet: SignerWithAddress, + amount: BigNumberish, + receiver: string, + userId: number, + chainid: BigNumberish, + nftAddress: string +) => { + const payload = ethers.AbiCoder.defaultAbiCoder().encode( + ["uint256", "address", "uint256", "uint256", "address"], + [amount, receiver, userId, chainid, nftAddress] + ); + const payloadHash = ethers.keccak256(payload); + return wallet.signMessage(ethers.getBytes(payloadHash)); +}; + +describe("ConfigurableGuildRewardNFT", () => { + before("get accounts, setup variables", async () => { + [wallet0, randomWallet, treasury, adminTreasury, signer] = await ethers.getSigners(); + + nftConfig = { + ...baseNFTConfig, + tokenOwner: wallet0.address, + treasury: adminTreasury.address + }; + + chainId = (await ethers.provider.getNetwork()).chainId; + }); + + beforeEach("deploy contract", async () => { + GuildRewardNFTFactory = await ethers.getContractFactory("GuildRewardNFTFactory"); + factory = await upgrades.deployProxy(GuildRewardNFTFactory, [treasury.address, fee, signer.address], { + kind: "uups" + }); + await factory.waitForDeployment(); + + ConfigurableGuildRewardNFT = await ethers.getContractFactory("ConfigurableGuildRewardNFT"); + nft = (await ConfigurableGuildRewardNFT.deploy()) as Contract; + await nft.waitForDeployment(); + await nft.initialize(nftConfig, await factory.getAddress()); + + nftMultipleMints = (await ConfigurableGuildRewardNFT.deploy()) as Contract; + await nftMultipleMints.waitForDeployment(); + await nftMultipleMints.initialize({ ...nftConfig, mintableAmountPerUser: 5 }, await factory.getAddress()); + + await factory.setNFTImplementation(ContractType.CONFIGURABLE_NFT, nft); + await factory.setFee(fee); + }); + + it("should have initialized the state variables", async () => { + expect(await nft.name()).to.eq(nftConfig.name); + expect(await nft.symbol()).to.eq(nftConfig.symbol); + expect(await nft.owner()).to.eq(wallet0.address); + expect(await nft.mintableAmountPerUser()).to.eq(nftConfig.mintableAmountPerUser); + expect(await nft.factoryProxy()).to.eq(await factory.getAddress()); + }); + + context("Claiming and burning", () => { + let sampleSignature: string; + + beforeEach("create signature", async () => { + sampleSignature = await createSignature( + signer, + sampleAmount, + wallet0.address, + sampleUserId, + chainId, + await nft.getAddress() + ); + }); + + context("#claim", () => { + it("should revert if the address has already claimed all of their tokens", async () => { + await nft.claim(sampleAmount, wallet0.address, sampleUserId, sampleSignature, { + value: fee + nftConfig.tokenFee + }); + await expect( + nft.claim(sampleAmount, wallet0.address, sampleUserId, sampleSignature, { + value: fee + nftConfig.tokenFee + }) + ).to.be.revertedWithCustomError(nft, "AlreadyClaimed"); + }); + + it("should revert if the userId has already claimed all of their tokens", async () => { + await nft.claim(sampleAmount, wallet0.address, sampleUserId, sampleSignature, { + value: fee + nftConfig.tokenFee + }); + await expect( + nft.claim(sampleAmount, randomWallet.address, sampleUserId, sampleSignature, { + value: fee + nftConfig.tokenFee + }) + ).to.be.revertedWithCustomError(nft, "AlreadyClaimed"); + }); + + it("should revert if the signature is incorrect", async () => { + await expect( + nft.claim(sampleAmount, wallet0.address, sampleUserId, ethers.ZeroHash, { + value: fee + nftConfig.tokenFee + }) + ).to.be.revertedWithCustomError(nft, "IncorrectSignature"); + + await expect( + nft.claim(sampleAmount, wallet0.address, sampleUserId, sampleSignature.slice(0, -2), { + value: fee + nftConfig.tokenFee + }) + ).to.be.revertedWithCustomError(nft, "IncorrectSignature"); + + await expect( + nft.claim( + sampleAmount, + wallet0.address, + sampleUserId, + await createSignature( + signer, + sampleAmount, + randomWallet.address, + sampleUserId, + chainId, + await nft.getAddress() + ), + { + value: fee + nftConfig.tokenFee + } + ) + ).to.be.revertedWithCustomError(nft, "IncorrectSignature"); + }); + + it("should increment the total supply", async () => { + const totalSupply0 = await nft.totalSupply(); + await nft.claim(sampleAmount, wallet0.address, sampleUserId, sampleSignature, { + value: fee + nftConfig.tokenFee + }); + const totalSupply1 = await nft.totalSupply(); + expect(totalSupply1).to.eq(totalSupply0 + 1n); + }); + + it("should increment the address' claimed tokens", async () => { + const userBalance0 = await nft["balanceOf(address)"](wallet0.address); + await nft.claim(sampleAmount, wallet0.address, sampleUserId, sampleSignature, { + value: fee + nftConfig.tokenFee + }); + const userBalance1 = await nft["balanceOf(address)"](wallet0.address); + expect(userBalance1).to.eq(userBalance0 + sampleAmount); + }); + + it("should increment the userId's claimed tokens", async () => { + const userBalance0 = await nft["balanceOf(uint256)"](sampleUserId); + await nft.claim(sampleAmount, wallet0.address, sampleUserId, sampleSignature, { + value: fee + nftConfig.tokenFee + }); + const userBalance1 = await nft["balanceOf(uint256)"](sampleUserId); + expect(userBalance1).to.eq(userBalance0 + sampleAmount); + }); + + it("should mint the token", async () => { + const totalSupply = await nft.totalSupply(); + const tokenId = totalSupply; + expect(await nft["balanceOf(address)"](wallet0.address)).to.eq(0); + await expect(nft.ownerOf(tokenId)).to.be.revertedWith("ERC721: invalid token ID"); + await nft.claim(sampleAmount, wallet0.address, sampleUserId, sampleSignature, { + value: fee + nftConfig.tokenFee + }); + expect(await nft["balanceOf(address)"](wallet0.address)).to.eq(1); + expect(await nft.ownerOf(tokenId)).to.eq(wallet0.address); + }); + + it("should mint multiple tokens", async () => { + const amount = 3n; + const signature = createSignature( + signer, + amount, + wallet0.address, + sampleUserId, + chainId, + await nftMultipleMints.getAddress() + ); + + await factory.setNFTImplementation(ContractType.CONFIGURABLE_NFT, nftMultipleMints); + + const totalSupply = await nftMultipleMints.totalSupply(); + const tokenId = totalSupply; + expect(await nftMultipleMints["balanceOf(address)"](wallet0.address)).to.eq(0); + await expect(nftMultipleMints.ownerOf(tokenId)).to.be.revertedWith("ERC721: invalid token ID"); + await nftMultipleMints.claim(amount, wallet0.address, sampleUserId, signature, { + value: (fee + nftConfig.tokenFee) * amount + }); + expect(await nftMultipleMints["balanceOf(address)"](wallet0.address)).to.eq(3); + expect(await nftMultipleMints.ownerOf(tokenId)).to.eq(wallet0.address); + }); + + it("should emit Locked event when minting soulbound tokens", async () => { + const tokenId = await nft.totalSupply(); + await expect( + nft.claim(sampleAmount, wallet0.address, sampleUserId, sampleSignature, { value: fee + nftConfig.tokenFee }) + ) + .to.emit(nft, "Locked") + .withArgs(tokenId); + }); + + it("should emit Claimed event", async () => { + await expect( + nft.claim(sampleAmount, wallet0.address, sampleUserId, sampleSignature, { + value: fee + nftConfig.tokenFee + }) + ) + .to.emit(nft, "Claimed") + .withArgs(wallet0.address, 0); + }); + + it("should transfer ether to both treasuries", async () => { + await expect( + nft.claim(sampleAmount, wallet0.address, sampleUserId, sampleSignature, { + value: fee + nftConfig.tokenFee + }) + ).to.changeEtherBalances( + [wallet0, treasury, adminTreasury], + [-(fee + nftConfig.tokenFee), fee, nftConfig.tokenFee] + ); + }); + + it("should revert if an incorrect msg.value is received", async () => { + await expect( + nft.claim(sampleAmount, wallet0.address, sampleUserId, sampleSignature, { + value: fee * 2n + }) + ) + .to.be.revertedWithCustomError(nft, "IncorrectFee") + .withArgs(fee * 2n, fee + nftConfig.tokenFee); + + await expect(nft.claim(sampleAmount, wallet0.address, sampleUserId, sampleSignature)) + .to.be.revertedWithCustomError(nft, "IncorrectFee") + .withArgs(0, fee + nftConfig.tokenFee); + }); + }); + + context("#burn", () => { + beforeEach("claim a token", async () => { + await nft.claim(sampleAmount, wallet0.address, sampleUserId, sampleSignature, { + value: fee + nftConfig.tokenFee + }); + }); + + it("should revert if a token is attempted to be burned by anyone but it's owner", async () => { + await expect( + (nft.connect(randomWallet) as Contract).burn( + [0], + sampleUserId, + createSignature(signer, 1, randomWallet.address, sampleUserId, chainId, await nft.getAddress()) + ) + ).to.be.revertedWithCustomError(nft, "IncorrectSender"); + }); + + it("should revert if the signature is incorrect", async () => { + await expect(nft.burn([0], sampleUserId, ethers.ZeroHash)).to.be.revertedWithCustomError( + nft, + "IncorrectSignature" + ); + + await expect(nft.burn([0], sampleUserId, sampleSignature.slice(0, -2))).to.be.revertedWithCustomError( + nft, + "IncorrectSignature" + ); + + await expect( + nft.burn( + [0], + sampleUserId, + await createSignature( + signer, + sampleAmount, + randomWallet.address, + sampleUserId, + chainId, + await nft.getAddress() + ) + ) + ).to.be.revertedWithCustomError(nft, "IncorrectSignature"); + }); + + it("should decrement the address' claimed tokens", async () => { + const userBalance0 = await nft["balanceOf(address)"](wallet0.address); + await nft.burn([0], sampleUserId, sampleSignature); + const userBalance1 = await nft["balanceOf(address)"](wallet0.address); + expect(userBalance1).to.eq(userBalance0 - 1n); + }); + + it("should decrement the userId's claimed tokens", async () => { + const userBalance0 = await nft["balanceOf(uint256)"](sampleUserId); + await nft.burn([0], sampleUserId, sampleSignature); + const userBalance1 = await nft["balanceOf(uint256)"](sampleUserId); + expect(userBalance1).to.eq(userBalance0 - 1n); + }); + + it("should decrement the total supply", async () => { + const totalSupply0 = await nft.totalSupply(); + await nft.burn([0], sampleUserId, sampleSignature); + const totalSupply1 = await nft.totalSupply(); + expect(totalSupply1).to.eq(totalSupply0 - 1n); + }); + + it("should burn the token", async () => { + const tokenId = 0; + const tokenOfOwnerByIndex = await nft.tokenOfOwnerByIndex(wallet0.address, 0); + expect(tokenOfOwnerByIndex).to.eq(tokenId); + + await nft.burn([tokenId], sampleUserId, sampleSignature); + + await expect(nft.tokenOfOwnerByIndex(wallet0.address, 0)).to.be.revertedWith( + "ERC721Enumerable: owner index out of bounds" + ); + }); + + it("should burn multiple tokens", async () => { + const signature = createSignature( + signer, + 2n, + wallet0.address, + sampleUserId, + chainId, + await nftMultipleMints.getAddress() + ); + + await factory.setNFTImplementation(ContractType.CONFIGURABLE_NFT, nftMultipleMints); + + await nftMultipleMints.claim(2n, wallet0.address, sampleUserId, signature, { + value: (fee + nftConfig.tokenFee) * 2n + }); + + const tokenIds = [0, 1]; + const tokenOfOwnerByIndex0 = await nftMultipleMints.tokenOfOwnerByIndex(wallet0.address, 0); + const tokenOfOwnerByIndex1 = await nftMultipleMints.tokenOfOwnerByIndex(wallet0.address, 1); + expect(tokenOfOwnerByIndex0).to.eq(tokenIds[0]); + expect(tokenOfOwnerByIndex1).to.eq(tokenIds[1]); + + await nftMultipleMints.burn(tokenIds, sampleUserId, signature); + + await expect(nftMultipleMints.tokenOfOwnerByIndex(wallet0.address, 0)).to.be.revertedWith( + "ERC721Enumerable: owner index out of bounds" + ); + await expect(nftMultipleMints.tokenOfOwnerByIndex(wallet0.address, 1)).to.be.revertedWith( + "ERC721Enumerable: owner index out of bounds" + ); + }); + }); + }); + + context("General config", () => { + context("#setLocked", () => { + beforeEach("claim a token", async () => { + await nft.claim( + sampleAmount, + wallet0.address, + sampleUserId, + await createSignature(signer, sampleAmount, wallet0.address, sampleUserId, chainId, await nft.getAddress()), + { + value: fee + nftConfig.tokenFee + } + ); + }); + + it("should revert if the lock status is attempted to be changed by anyone but the owner", async () => { + await expect((nft.connect(randomWallet) as Contract).setLocked(false)).to.be.revertedWith( + "Ownable: caller is not the owner" + ); + }); + + it("should update locked status", async () => { + const locked = false; + await nft.setLocked(locked); + const newLocked = await nft.locked(0); + expect(newLocked).to.eq(locked); + }); + + it("should emit Locked/Unlocked event", async () => { + await expect(nft.setLocked(false)).to.emit(nft, "Unlocked").withArgs(0); + await expect(nft.setLocked(true)).to.emit(nft, "Locked").withArgs(0); + }); + }); + + context("#setMintableAmountPerUser", () => { + it("should revert if mintableAmountPerUser is attempted to be changed by anyone but the owner", async () => { + await expect((nft.connect(randomWallet) as Contract).setMintableAmountPerUser(5)).to.be.revertedWith( + "Ownable: caller is not the owner" + ); + }); + + it("should update mintableAmountPerUser", async () => { + const mintableAmountPerUser = 5; + await nft.setMintableAmountPerUser(mintableAmountPerUser); + const newMintableAmountPerUser = await nft.mintableAmountPerUser(); + expect(newMintableAmountPerUser).to.eq(mintableAmountPerUser); + }); + + it("should emit MintableAmountPerUserChanged event", async () => { + const mintableAmountPerUser = 5; + await expect(nft.setMintableAmountPerUser(mintableAmountPerUser)) + .to.emit(nft, "MintableAmountPerUserChanged") + .withArgs(mintableAmountPerUser); + }); + }); + }); + + context("TokenURI", () => { + let signature: string; + + beforeEach("create signature, set strings", async () => { + signature = await createSignature( + signer, + sampleAmount, + wallet0.address, + sampleUserId, + chainId, + await nft.getAddress() + ); + }); + + context("#tokenURI", () => { + it("should revert when trying to get the tokenURI for a non-existent token", async () => { + await expect(nft.tokenURI(84)).to.revertedWithCustomError(nft, "NonExistentToken").withArgs(84); + }); + + it("should return the correct tokenURI", async () => { + await nft.claim(sampleAmount, wallet0.address, sampleUserId, signature, { + value: fee + nftConfig.tokenFee + }); + + const tokenURI = await nft.tokenURI(0); + const regex = new RegExp(`ipfs://${nftConfig.cid}`); + expect(regex.test(tokenURI)).to.eq(true); + }); + }); + + context("#updateTokenURI", () => { + beforeEach("claim a token", async () => { + await nft.claim(sampleAmount, wallet0.address, sampleUserId, signature, { + value: fee + nftConfig.tokenFee + }); + }); + + it("should revert if the cid is attempted to be changed by anyone but the owner", async () => { + await expect((nft.connect(randomWallet) as Contract).updateTokenURI(sampleCids[1])).to.be.revertedWith( + "Ownable: caller is not the owner" + ); + }); + + it("should update cid", async () => { + const oldTokenURI = await nft.tokenURI(0); + await nft.updateTokenURI(sampleCids[1]); + const newTokenURI = await nft.tokenURI(0); + expect(newTokenURI).to.not.eq(oldTokenURI); + expect(newTokenURI).to.contain(sampleCids[1]); + }); + + it("should emit MetadataUpdate event", async () => { + await expect(nft.updateTokenURI(sampleCids[1])).to.emit(nft, "MetadataUpdate").withArgs(); + }); + }); + }); +}); diff --git a/test/GuildRewardNFTFactory.spec.ts b/test/GuildRewardNFTFactory.spec.ts index 263161a..4b072ea 100644 --- a/test/GuildRewardNFTFactory.spec.ts +++ b/test/GuildRewardNFTFactory.spec.ts @@ -4,11 +4,13 @@ import { expect } from "chai"; import { Contract, ContractFactory } from "ethers"; import { ethers, upgrades } from "hardhat"; -/// NFT CONFIG +// NFT CONFIG const sampleName = "Test Guild Passport"; const sampleSymbol = "TGP"; const cids = ["QmPaZD7i8TpLEeGjHtGoXe4mPKbRNNt8YTHH5nrKoqz9wJ", "QmcaGypWsmzaSQQGuExUjtyTRvZ2FF525Ww6PBNWWgkkLj"]; const sampleFee = 69; +const sampleSoulbound = true; +const sampleMintableAmountPerUser = 1; // CONTRACTS let GuildRewardNFTFactory: ContractFactory; @@ -21,7 +23,8 @@ let treasury: SignerWithAddress; let signer: SignerWithAddress; enum ContractType { - BASIC_NFT + BASIC_NFT, + CONFIGURABLE_NFT } describe("GuildRewardNFTFactory", () => { @@ -48,7 +51,7 @@ describe("GuildRewardNFTFactory", () => { expect(await upgraded.owner()).to.eq(wallet0.address); }); - it("should deploy and initialize clones", async () => { + it("should deploy and initialize clones of BasicGuildRewardNFT", async () => { const basicGuildRewardNFT = await ethers.getContractFactory("BasicGuildRewardNFT"); const nftMain = (await basicGuildRewardNFT.deploy()) as Contract; await nftMain.waitForDeployment(); @@ -63,10 +66,35 @@ describe("GuildRewardNFTFactory", () => { expect(await nft.fee()).to.eq(sampleFee); }); + it("should deploy and initialize clones of ConfigurableGuildRewardNFT", async () => { + const configurableGuildRewardNFT = await ethers.getContractFactory("ConfigurableGuildRewardNFT"); + const nftMain = (await configurableGuildRewardNFT.deploy()) as Contract; + await nftMain.waitForDeployment(); + await factory.setNFTImplementation(ContractType.CONFIGURABLE_NFT, nftMain); + + await factory.deployConfigurableNFT({ + name: sampleName, + symbol: sampleSymbol, + cid: cids[0], + tokenOwner: randomWallet.address, + treasury: treasury.address, + tokenFee: sampleFee, + soulbound: sampleSoulbound, + mintableAmountPerUser: sampleMintableAmountPerUser + }); + const nftAddresses = await factory.getDeployedTokenContracts(wallet0.address); + const nft = nftMain.attach(nftAddresses[0].contractAddress) as Contract; + expect(await nft.name()).to.eq(sampleName); + expect(await nft.symbol()).to.eq(sampleSymbol); + expect(await nft.owner()).to.eq(randomWallet.address); + expect(await nft.fee()).to.eq(sampleFee); + expect(await nft.mintableAmountPerUser()).to.eq(sampleMintableAmountPerUser); + }); + it("should emit RewardNFTDeployed event", async () => { await expect(factory.deployBasicNFT(sampleName, sampleSymbol, cids[0], wallet0.address, treasury.address, 0)) .to.emit(factory, "RewardNFTDeployed") - .withArgs(wallet0.address, anyValue); + .withArgs(wallet0.address, anyValue, ContractType.BASIC_NFT); }); context("#setNFTImplementation", () => {