diff --git a/contracts/SPGNFT.sol b/contracts/SPGNFT.sol index dcd0dcf..d419e7f 100644 --- a/contracts/SPGNFT.sol +++ b/contracts/SPGNFT.sol @@ -17,12 +17,17 @@ contract SPGNFT is ISPGNFT, ERC721URIStorageUpgradeable, AccessControlUpgradeabl /// @param totalSupply The total minted supply of the collection. /// @param mintFee The fee to mint an NFT from the collection. /// @param mintFeeToken The token to pay for minting. + /// @param mintFeeRecipient The address to receive mint fees. + /// @param mintOpen The status of minting, whether it is open or not. /// @custom:storage-location erc7201:story-protocol-periphery.SPGNFT struct SPGNFTStorage { uint32 maxSupply; uint32 totalSupply; uint256 mintFee; address mintFeeToken; + address mintFeeRecipient; + bool mintOpen; + bool publicMinting; } // keccak256(abi.encode(uint256(keccak256("story-protocol-periphery.SPGNFT")) - 1)) & ~bytes32(uint256(0xff)); @@ -58,18 +63,24 @@ contract SPGNFT is ISPGNFT, ERC721URIStorageUpgradeable, AccessControlUpgradeabl /// @param maxSupply The maximum supply of the collection. /// @param mintFee The fee to mint an NFT from the collection. /// @param mintFeeToken The token to pay for minting. - /// @param owner The owner of the collection. + /// @param mintFeeRecipient The address to receive mint fees. + /// @param owner The owner of the collection. Zero address indicates no owner. + /// @param mintOpen Whether the collection is open for minting on creation. Configurable by the owner. + /// @param isPublicMinting If true, anyone can mint from the collection. If false, only the addresses with the + /// minter role can mint. Configurable by the owner. function initialize( string memory name, string memory symbol, uint32 maxSupply, uint256 mintFee, address mintFeeToken, - address owner + address mintFeeRecipient, + address owner, + bool mintOpen, + bool isPublicMinting ) public initializer { - if (owner == address(0) || (mintFee > 0 && mintFeeToken == address(0))) - revert Errors.SPGNFT__ZeroAddressParam(); - if (maxSupply == 0) revert Errors.SPGNFT_ZeroMaxSupply(); + if (mintFee > 0 && mintFeeToken == address(0)) revert Errors.SPGNFT__ZeroAddressParam(); + if (maxSupply == 0) revert Errors.SPGNFT__ZeroMaxSupply(); _grantRole(SPGNFTLib.ADMIN_ROLE, owner); _grantRole(SPGNFTLib.MINTER_ROLE, owner); @@ -84,6 +95,9 @@ contract SPGNFT is ISPGNFT, ERC721URIStorageUpgradeable, AccessControlUpgradeabl $.maxSupply = maxSupply; $.mintFee = mintFee; $.mintFeeToken = mintFeeToken; + $.mintFeeRecipient = mintFeeRecipient; + $.mintOpen = mintOpen; + $.publicMinting = isPublicMinting; __ERC721_init(name, symbol); } @@ -93,14 +107,36 @@ contract SPGNFT is ISPGNFT, ERC721URIStorageUpgradeable, AccessControlUpgradeabl return uint256(_getSPGNFTStorage().totalSupply); } + /// @notice Returns the current mint fee of the collection. + function mintFee() public view returns (uint256) { + return _getSPGNFTStorage().mintFee; + } + /// @notice Returns the current mint token of the collection. function mintFeeToken() public view returns (address) { return _getSPGNFTStorage().mintFeeToken; } - /// @notice Returns the current mint fee of the collection. - function mintFee() public view returns (uint256) { - return _getSPGNFTStorage().mintFee; + /// @notice Returns the current mint fee recipient of the collection. + function mintFeeRecipient() public view returns (address) { + return _getSPGNFTStorage().mintFeeRecipient; + } + + /// @notice Returns true if the collection is open for minting. + function mintOpen() public view returns (bool) { + return _getSPGNFTStorage().mintOpen; + } + + /// @notice Returns true if the collection is open for public minting. + function publicMinting() public view returns (bool) { + return _getSPGNFTStorage().publicMinting; + } + + /// @notice Sets the fee to mint an NFT from the collection. Payment is in the designated currency. + /// @dev Only callable by the admin role. + /// @param fee The new mint fee paid in the mint token. + function setMintFee(uint256 fee) public onlyRole(SPGNFTLib.ADMIN_ROLE) { + _getSPGNFTStorage().mintFee = fee; } /// @notice Sets the mint token for the collection. @@ -110,21 +146,38 @@ contract SPGNFT is ISPGNFT, ERC721URIStorageUpgradeable, AccessControlUpgradeabl _getSPGNFTStorage().mintFeeToken = token; } - /// @notice Sets the fee to mint an NFT from the collection. Payment is in the designated currency. + /// @notice Sets the recipient of mint fees. + /// @dev Only callable by the fee recipient. + /// @param newFeeRecipient The new fee recipient. + function setMintFeeRecipient(address newFeeRecipient) public { + if (msg.sender != _getSPGNFTStorage().mintFeeRecipient) { + revert Errors.SPGNFT__CallerNotFeeRecipient(); + } + _getSPGNFTStorage().mintFeeRecipient = newFeeRecipient; + } + + /// @notice Sets the minting status. /// @dev Only callable by the admin role. - /// @param fee The new mint fee paid in the mint token. - function setMintFee(uint256 fee) public onlyRole(SPGNFTLib.ADMIN_ROLE) { - _getSPGNFTStorage().mintFee = fee; + /// @param mintOpen Whether minting is open or not. + function setMintOpen(bool mintOpen) public onlyRole(SPGNFTLib.ADMIN_ROLE) { + _getSPGNFTStorage().mintOpen = mintOpen; + } + + /// @notice Sets the public minting status. + /// @dev Only callable by the admin role. + /// @param isPublicMinting Whether the collection is open for public minting or not. + function setPublicMinting(bool isPublicMinting) public onlyRole(SPGNFTLib.ADMIN_ROLE) { + _getSPGNFTStorage().publicMinting = isPublicMinting; } /// @notice Mints an NFT from the collection. Only callable by the minter role. /// @param to The address of the recipient of the minted NFT. /// @param nftMetadataURI OPTIONAL. The URI of the desired metadata for the newly minted NFT. /// @return tokenId The ID of the minted NFT. - function mint( - address to, - string calldata nftMetadataURI - ) public virtual onlyRole(SPGNFTLib.MINTER_ROLE) returns (uint256 tokenId) { + function mint(address to, string calldata nftMetadataURI) public virtual returns (uint256 tokenId) { + if (!_getSPGNFTStorage().publicMinting && !hasRole(SPGNFTLib.MINTER_ROLE, msg.sender)) { + revert Errors.SPGNFT__MintingDenied(); + } tokenId = _mintToken({ to: to, payer: msg.sender, nftMetadataURI: nftMetadataURI }); } @@ -141,11 +194,10 @@ contract SPGNFT is ISPGNFT, ERC721URIStorageUpgradeable, AccessControlUpgradeabl tokenId = _mintToken({ to: to, payer: payer, nftMetadataURI: nftMetadataURI }); } - /// @dev Withdraws the contract's token balance to the recipient. - /// @param recipient The token to withdraw. - /// @param recipient The address to receive the withdrawn balance. - function withdrawToken(address token, address recipient) public onlyRole(SPGNFTLib.ADMIN_ROLE) { - IERC20(token).transfer(recipient, IERC20(token).balanceOf(address(this))); + /// @dev Withdraws the contract's token balance to the fee recipient. + /// @param token The token to withdraw. + function withdrawToken(address token) public { + IERC20(token).transfer(_getSPGNFTStorage().mintFeeRecipient, IERC20(token).balanceOf(address(this))); } /// @dev Supports ERC165 interface. @@ -163,6 +215,7 @@ contract SPGNFT is ISPGNFT, ERC721URIStorageUpgradeable, AccessControlUpgradeabl /// @return tokenId The ID of the minted NFT. function _mintToken(address to, address payer, string calldata nftMetadataURI) internal returns (uint256 tokenId) { SPGNFTStorage storage $ = _getSPGNFTStorage(); + if (!$.mintOpen) revert Errors.SPGNFT__MintingClosed(); if ($.totalSupply + 1 > $.maxSupply) revert Errors.SPGNFT__MaxSupplyReached(); if ($.mintFeeToken != address(0) && $.mintFee > 0) { diff --git a/contracts/StoryProtocolGateway.sol b/contracts/StoryProtocolGateway.sol index 44f4533..d69e23c 100644 --- a/contracts/StoryProtocolGateway.sol +++ b/contracts/StoryProtocolGateway.sol @@ -121,7 +121,11 @@ contract StoryProtocolGateway is /// @param maxSupply The maximum supply of the collection. /// @param mintFee The cost to mint an NFT from the collection. /// @param mintFeeToken The token to be used for mint payment. - /// @param owner The owner of the collection. + /// @param mintFeeRecipient The address to receive mint fees. + /// @param owner The owner of the collection. Zero address indicates no owner. + /// @param mintOpen Whether the collection is open for minting on creation. Configurable by the owner. + /// @param isPublicMinting If true, anyone can mint from the collection. If false, only the addresses with the + /// minter role can mint. Configurable by the owner. /// @return nftContract The address of the newly created NFT collection. function createCollection( string calldata name, @@ -129,10 +133,23 @@ contract StoryProtocolGateway is uint32 maxSupply, uint256 mintFee, address mintFeeToken, - address owner + address mintFeeRecipient, + address owner, + bool mintOpen, + bool isPublicMinting ) external returns (address nftContract) { nftContract = address(new BeaconProxy(_getSPGStorage().nftContractBeacon, "")); - ISPGNFT(nftContract).initialize(name, symbol, maxSupply, mintFee, mintFeeToken, owner); + ISPGNFT(nftContract).initialize( + name, + symbol, + maxSupply, + mintFee, + mintFeeToken, + mintFeeRecipient, + owner, + mintOpen, + isPublicMinting + ); emit CollectionCreated(nftContract); } diff --git a/contracts/interfaces/ISPGNFT.sol b/contracts/interfaces/ISPGNFT.sol index 91805c8..ee80c53 100644 --- a/contracts/interfaces/ISPGNFT.sol +++ b/contracts/interfaces/ISPGNFT.sol @@ -7,40 +7,71 @@ import { IERC721Metadata } from "@openzeppelin/contracts/token/ERC721/extensions interface ISPGNFT is IAccessControl, IERC721, IERC721Metadata { /// @dev Initializes the NFT collection. - /// @dev If mint cost is non-zero, mint token must be set. + /// @dev If mint fee is non-zero, mint token must be set. /// @param name The name of the collection. /// @param symbol The symbol of the collection. /// @param maxSupply The maximum supply of the collection. - /// @param mintFee The cost to mint an NFT from the collection. + /// @param mintFee The fee to mint an NFT from the collection. /// @param mintFeeToken The token to pay for minting. - /// @param owner The owner of the collection. + /// @param mintFeeRecipient The address to receive mint fees. + /// @param owner The owner of the collection. Zero address indicates no owner. + /// @param mintOpen Whether the collection is open for minting on creation. Configurable by the owner. + /// @param isPublicMinting If true, anyone can mint from the collection. If false, only the addresses with the + /// minter role can mint. Configurable by the owner. function initialize( string memory name, string memory symbol, uint32 maxSupply, uint256 mintFee, address mintFeeToken, - address owner + address mintFeeRecipient, + address owner, + bool mintOpen, + bool isPublicMinting ) external; /// @notice Returns the total minted supply of the collection. function totalSupply() external view returns (uint256); + /// @notice Returns the current mint fee of the collection. + function mintFee() external view returns (uint256); + /// @notice Returns the current mint token of the collection. function mintFeeToken() external view returns (address); - /// @notice Returns the current mint fee of the collection. - function mintFee() external view returns (uint256); + /// @notice Returns the current mint fee recipient of the collection. + function mintFeeRecipient() external view returns (address); + + /// @notice Returns true if the collection is open for minting. + function mintOpen() external view returns (bool); + + /// @notice Returns true if the collection is open for public minting. + function publicMinting() external view returns (bool); + + /// @notice Sets the fee to mint an NFT from the collection. Payment is in the designated currency. + /// @dev Only callable by the admin role. + /// @param fee The new mint fee paid in the mint token. + function setMintFee(uint256 fee) external; /// @notice Sets the mint token for the collection. /// @dev Only callable by the admin role. /// @param token The new mint token for mint payment. function setMintFeeToken(address token) external; - /// @notice Sets the fee to mint an NFT from the collection. Payment is in the designated currency. + /// @notice Sets the recipient of mint fees. + /// @dev Only callable by the fee recipient. + /// @param newFeeRecipient The new fee recipient. + function setMintFeeRecipient(address newFeeRecipient) external; + + /// @notice Sets the minting status. /// @dev Only callable by the admin role. - /// @param fee The new mint fee paid in the mint token. - function setMintFee(uint256 fee) external; + /// @param mintOpen Whether minting is open or not. + function setMintOpen(bool mintOpen) external; + + /// @notice Sets the public minting status. + /// @dev Only callable by the admin role. + /// @param isPublicMinting Whether the collection is open for public minting or not. + function setPublicMinting(bool isPublicMinting) external; /// @notice Mints an NFT from the collection. Only callable by the minter role. /// @param to The address of the recipient of the minted NFT. @@ -59,8 +90,7 @@ interface ISPGNFT is IAccessControl, IERC721, IERC721Metadata { string calldata nftMetadataURI ) external returns (uint256 tokenId); - /// @dev Withdraws the contract's token balance to the recipient. - /// @param recipient The token to withdraw. - /// @param recipient The address to receive the withdrawn balance. - function withdrawToken(address token, address recipient) external; + /// @dev Withdraws the contract's token balance to the fee recipient. + /// @param token The token to withdraw. + function withdrawToken(address token) external; } diff --git a/contracts/interfaces/IStoryProtocolGateway.sol b/contracts/interfaces/IStoryProtocolGateway.sol index f01e3a7..1159045 100644 --- a/contracts/interfaces/IStoryProtocolGateway.sol +++ b/contracts/interfaces/IStoryProtocolGateway.sol @@ -50,7 +50,11 @@ interface IStoryProtocolGateway { /// @param maxSupply The maximum supply of the collection. /// @param mintFee The cost to mint an NFT from the collection. /// @param mintFeeToken The token to be used for mint payment. - /// @param owner The owner of the collection. + /// @param mintFeeRecipient The address to receive mint fees. + /// @param owner The owner of the collection. Zero address indicates no owner. + /// @param mintOpen Whether the collection is open for minting on creation. Configurable by the owner. + /// @param isPublicMinting If true, anyone can mint from the collection. If false, only the addresses with the + /// minter role can mint. Configurable by the owner. /// @return nftContract The address of the newly created NFT collection. function createCollection( string calldata name, @@ -58,7 +62,10 @@ interface IStoryProtocolGateway { uint32 maxSupply, uint256 mintFee, address mintFeeToken, - address owner + address mintFeeRecipient, + address owner, + bool mintOpen, + bool isPublicMinting ) external returns (address nftContract); /// @notice Mint an NFT from a SPGNFT collection and register it with metadata as an IP. diff --git a/contracts/lib/Errors.sol b/contracts/lib/Errors.sol index 2698c28..5c58163 100644 --- a/contracts/lib/Errors.sol +++ b/contracts/lib/Errors.sol @@ -20,11 +20,20 @@ library Errors { error SPGNFT__ZeroAddressParam(); /// @notice Zero max supply provided. - error SPGNFT_ZeroMaxSupply(); + error SPGNFT__ZeroMaxSupply(); /// @notice Max mint supply reached. error SPGNFT__MaxSupplyReached(); + /// @notice Minting is denied if the public minting is false (=> private) but caller does not have the minter role. + error SPGNFT__MintingDenied(); + + /// @notice Caller is not the fee recipient. + error SPGNFT__CallerNotFeeRecipient(); + + /// @notice Minting is closed. + error SPGNFT__MintingClosed(); + /// @notice Caller is not one of the periphery contracts. error SPGNFT__CallerNotPeripheryContract(); diff --git a/test/SPGNFT.t.sol b/test/SPGNFT.t.sol index d1edbf0..987e2fe 100644 --- a/test/SPGNFT.t.sol +++ b/test/SPGNFT.t.sol @@ -20,6 +20,8 @@ contract SPGNFTTest is BaseTest { function setUp() public override { super.setUp(); + feeRecipient = address(0xbeef); + nftContract = ISPGNFT( spg.createCollection({ name: "Test Collection", @@ -27,7 +29,10 @@ contract SPGNFTTest is BaseTest { maxSupply: 100, mintFee: 100 * 10 ** mockToken.decimals(), mintFeeToken: address(mockToken), - owner: alice + mintFeeRecipient: alice, + owner: alice, + mintOpen: true, + isPublicMinting: false }) ); @@ -46,7 +51,10 @@ contract SPGNFTTest is BaseTest { maxSupply: 100, mintFee: 100 * 10 ** mockToken.decimals(), mintFeeToken: address(mockToken), - owner: alice + mintFeeRecipient: feeRecipient, + owner: alice, + mintOpen: true, + isPublicMinting: false }); assertEq(nftContract.name(), anotherNftContract.name()); @@ -54,6 +62,10 @@ contract SPGNFTTest is BaseTest { assertEq(nftContract.totalSupply(), anotherNftContract.totalSupply()); assertTrue(anotherNftContract.hasRole(SPGNFTLib.MINTER_ROLE, alice)); assertEq(anotherNftContract.mintFee(), 100 * 10 ** mockToken.decimals()); + assertEq(anotherNftContract.mintFeeToken(), address(mockToken)); + assertEq(anotherNftContract.mintFeeRecipient(), feeRecipient); + assertTrue(anotherNftContract.mintOpen()); + assertFalse(anotherNftContract.publicMinting()); } function test_SPGNFT_initialize_revert_zeroParams() public { @@ -61,16 +73,6 @@ contract SPGNFTTest is BaseTest { address NFT_CONTRACT_BEACON = address(new UpgradeableBeacon(spgNftImpl, deployer)); nftContract = ISPGNFT(address(new BeaconProxy(NFT_CONTRACT_BEACON, ""))); - vm.expectRevert(Errors.SPGNFT__ZeroAddressParam.selector); - nftContract.initialize({ - name: "Test Collection", - symbol: "TEST", - maxSupply: 100, - mintFee: 0, - mintFeeToken: address(mockToken), - owner: address(0) - }); - vm.expectRevert(Errors.SPGNFT__ZeroAddressParam.selector); nftContract.initialize({ name: "Test Collection", @@ -78,17 +80,23 @@ contract SPGNFTTest is BaseTest { maxSupply: 100, mintFee: 1, mintFeeToken: address(0), - owner: alice + mintFeeRecipient: feeRecipient, + owner: alice, + mintOpen: true, + isPublicMinting: false }); - vm.expectRevert(Errors.SPGNFT_ZeroMaxSupply.selector); + vm.expectRevert(Errors.SPGNFT__ZeroMaxSupply.selector); nftContract.initialize({ name: "Test Collection", symbol: "TEST", maxSupply: 0, mintFee: 0, mintFeeToken: address(mockToken), - owner: alice + mintFeeRecipient: feeRecipient, + owner: alice, + mintOpen: true, + isPublicMinting: false }); } @@ -201,6 +209,9 @@ contract SPGNFTTest is BaseTest { } function test_SPGNFT_withdrawToken() public { + vm.prank(alice); + nftContract.setMintFeeRecipient(feeRecipient); + vm.startPrank(alice); mockToken.mint(address(alice), 1000 * 10 ** mockToken.decimals()); @@ -208,25 +219,15 @@ contract SPGNFTTest is BaseTest { uint256 mintFee = nftContract.mintFee(); - nftContract.mint(bob, nftMetadataDefault); + nftContract.mint(feeRecipient, nftMetadataDefault); + assertEq(mockToken.balanceOf(address(nftContract)), mintFee); - uint256 balanceBeforeBob = mockToken.balanceOf(bob); + uint256 balanceBeforeFeeRecipient = mockToken.balanceOf(feeRecipient); - nftContract.withdrawToken(address(mockToken), bob); + nftContract.withdrawToken(address(mockToken)); assertEq(mockToken.balanceOf(address(nftContract)), 0); - assertEq(mockToken.balanceOf(bob), balanceBeforeBob + mintFee); - - vm.stopPrank(); - } - - function test_SPGNFT_revert_withdrawETH_accessControlUnauthorizedAccount() public { - vm.startPrank(bob); - - vm.expectRevert( - abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, bob, SPGNFTLib.ADMIN_ROLE) - ); - nftContract.withdrawToken(address(mockToken), bob); + assertEq(mockToken.balanceOf(feeRecipient), balanceBeforeFeeRecipient + mintFee); vm.stopPrank(); } diff --git a/test/StoryProtocolGateway.t.sol b/test/StoryProtocolGateway.t.sol index 5b36e8a..14630f6 100644 --- a/test/StoryProtocolGateway.t.sol +++ b/test/StoryProtocolGateway.t.sol @@ -27,16 +27,19 @@ contract StoryProtocolGatewayTest is BaseTest { function setUp() public override { super.setUp(); minter = alice; + feeRecipient = bob; } function test_SPG_createCollection() public withCollection { - uint256 mintFee = nftContract.mintFee(); - assertEq(nftContract.name(), "Test Collection"); assertEq(nftContract.symbol(), "TEST"); assertEq(nftContract.totalSupply(), 0); assertTrue(nftContract.hasRole(SPGNFTLib.MINTER_ROLE, alice)); - assertEq(mintFee, 100 * 10 ** mockToken.decimals()); + assertEq(nftContract.mintFee(), 100 * 10 ** mockToken.decimals()); + assertEq(nftContract.mintFeeToken(), address(mockToken)); + assertEq(nftContract.mintFeeRecipient(), bob); + assertTrue(nftContract.mintOpen()); + assertFalse(nftContract.publicMinting()); } modifier whenCallerDoesNotHaveMinterRole() { @@ -428,14 +431,17 @@ contract StoryProtocolGatewayTest is BaseTest { nftContracts = new ISPGNFT[](10); bytes[] memory data = new bytes[](10); for (uint256 i = 0; i < 10; i++) { - data[i] = abi.encodeWithSignature( - "createCollection(string,string,uint32,uint256,address,address)", + data[i] = abi.encodeWithSelector( + spg.createCollection.selector, "Test Collection", "TEST", 100, 100 * 10 ** mockToken.decimals(), address(mockToken), - minter + feeRecipient, + minter, + true, + false ); } @@ -450,6 +456,10 @@ contract StoryProtocolGatewayTest is BaseTest { assertEq(nftContracts[i].totalSupply(), 0); assertTrue(nftContracts[i].hasRole(SPGNFTLib.MINTER_ROLE, alice)); assertEq(nftContracts[i].mintFee(), 100 * 10 ** mockToken.decimals()); + assertEq(nftContracts[i].mintFeeToken(), address(mockToken)); + assertEq(nftContracts[i].mintFeeRecipient(), bob); + assertTrue(nftContracts[i].mintOpen()); + assertFalse(nftContracts[i].publicMinting()); } } diff --git a/test/utils/BaseTest.t.sol b/test/utils/BaseTest.t.sol index 29a560f..f38e51d 100644 --- a/test/utils/BaseTest.t.sol +++ b/test/utils/BaseTest.t.sol @@ -91,6 +91,7 @@ contract BaseTest is Test { address internal minter; address internal caller; + address internal feeRecipient; ISPGNFT internal nftContract; ISPGNFT[] internal nftContracts; @@ -106,7 +107,10 @@ contract BaseTest is Test { maxSupply: 100, mintFee: 100 * 10 ** mockToken.decimals(), mintFeeToken: address(mockToken), - owner: minter + mintFeeRecipient: feeRecipient, + owner: minter, + mintOpen: true, + isPublicMinting: false }) ); _; @@ -221,6 +225,7 @@ contract BaseTest is Test { address(ipGraphACL) ) ); + licenseRegistry = LicenseRegistry( TestProxyHelper.deployUUPSProxy( create3Deployer,