From 087f63e2b19378cd54066d4e007de5919bf2c71b Mon Sep 17 00:00:00 2001 From: Sebastian Liu Date: Tue, 27 Aug 2024 19:40:11 -0700 Subject: [PATCH] feat(spg): add group IPA features --- contracts/StoryProtocolGateway.sol | 252 ++++++++++++---- .../interfaces/IStoryProtocolGateway.sol | 110 +++++-- script/Main.s.sol | 8 +- script/UpgradeSPG.s.sol | 4 +- .../utils/StoryProtocolCoreAddressManager.sol | 4 + test/StoryProtocolGateway.t.sol | 275 +++++++++++++++++- test/mocks/MockEvenSplitGroupPool.sol | 138 +++++++++ test/utils/BaseTest.t.sol | 101 ++++++- 8 files changed, 785 insertions(+), 107 deletions(-) create mode 100644 test/mocks/MockEvenSplitGroupPool.sol diff --git a/contracts/StoryProtocolGateway.sol b/contracts/StoryProtocolGateway.sol index b52bb8c..358fb7e 100644 --- a/contracts/StoryProtocolGateway.sol +++ b/contracts/StoryProtocolGateway.sol @@ -13,12 +13,14 @@ import { MulticallUpgradeable } from "@openzeppelin/contracts-upgradeable/utils/ import { AccessManagedUpgradeable } from "@openzeppelin/contracts-upgradeable/access/manager/AccessManagedUpgradeable.sol"; import { Licensing } from "@storyprotocol/core/lib/Licensing.sol"; +import { GroupNFT } from "@storyprotocol/core/GroupNFT.sol"; import { IIPAccount } from "@storyprotocol/core/interfaces/IIPAccount.sol"; import { AccessPermission } from "@storyprotocol/core/lib/AccessPermission.sol"; import { ILicenseToken } from "@storyprotocol/core/interfaces/ILicenseToken.sol"; import { IAccessController } from "@storyprotocol/core/interfaces/access/IAccessController.sol"; import { ILicenseRegistry } from "@storyprotocol/core/interfaces/registries/ILicenseRegistry.sol"; import { IIPAssetRegistry } from "@storyprotocol/core/interfaces/registries/IIPAssetRegistry.sol"; +import { IGroupingModule } from "@storyprotocol/core/interfaces/modules/grouping/IGroupingModule.sol"; import { IRoyaltyModule } from "@storyprotocol/core/interfaces/modules/royalty/IRoyaltyModule.sol"; import { ILicensingHook } from "@storyprotocol/core/interfaces/modules/licensing/ILicensingHook.sol"; import { ILicensingModule } from "@storyprotocol/core/interfaces/modules/licensing/ILicensingModule.sol"; @@ -76,6 +78,12 @@ contract StoryProtocolGateway is /// @notice The address of the License Token. ILicenseToken public immutable LICENSE_TOKEN; + /// @notice The address of the Grouping Module. + IGroupingModule public immutable GROUPING_MODULE; + + /// @notice The address of the Group NFT contract. + GroupNFT public immutable GROUP_NFT; + /// @notice Check that the caller has the minter role for the provided SPG NFT. /// @param nftContract The address of the SPG NFT. modifier onlyCallerWithMinterRole(address nftContract) { @@ -92,7 +100,9 @@ contract StoryProtocolGateway is address royaltyModule, address coreMetadataModule, address pilTemplate, - address licenseToken + address licenseToken, + address groupingModule, + address groupNft ) { if ( accessController == address(0) || @@ -101,7 +111,9 @@ contract StoryProtocolGateway is licenseRegistry == address(0) || royaltyModule == address(0) || coreMetadataModule == address(0) || - licenseToken == address(0) + licenseToken == address(0) || + groupingModule == address(0) || + groupNft == address(0) ) revert Errors.SPG__ZeroAddressParam(); ACCESS_CONTROLLER = IAccessController(accessController); @@ -112,6 +124,8 @@ contract StoryProtocolGateway is CORE_METADATA_MODULE = ICoreMetadataModule(coreMetadataModule); PIL_TEMPLATE = IPILicenseTemplate(pilTemplate); LICENSE_TOKEN = ILicenseToken(licenseToken); + GROUPING_MODULE = IGroupingModule(groupingModule); + GROUP_NFT = GroupNFT(groupNft); _disableInitializers(); } @@ -160,26 +174,26 @@ contract StoryProtocolGateway is emit CollectionCreated(nftContract); } - /// @notice Mint an NFT from a collection and register it with metadata as an IP. + /// @notice Mint an NFT from a SPGNFT collection and register it with metadata as an IP. /// @dev Caller must have the minter role for the provided SPG NFT. - /// @param nftContract The address of the NFT collection. + /// @param spgNftContract The address of the SPGNFT collection. /// @param recipient The address of the recipient of the minted NFT. /// @param ipMetadata OPTIONAL. The desired metadata for the newly minted NFT and registered IP. /// @return ipId The ID of the registered IP. - /// @return tokenId The ID of the minted NFT. + /// @return tokenId The ID of the newly minted NFT. function mintAndRegisterIp( - address nftContract, + address spgNftContract, address recipient, IPMetadata calldata ipMetadata - ) external onlyCallerWithMinterRole(nftContract) returns (address ipId, uint256 tokenId) { - tokenId = ISPGNFT(nftContract).mintBySPG({ + ) external onlyCallerWithMinterRole(spgNftContract) returns (address ipId, uint256 tokenId) { + tokenId = ISPGNFT(spgNftContract).mintBySPG({ to: address(this), payer: msg.sender, nftMetadataURI: ipMetadata.nftMetadataURI }); - ipId = IP_ASSET_REGISTRY.register(block.chainid, nftContract, tokenId); + ipId = IP_ASSET_REGISTRY.register(block.chainid, spgNftContract, tokenId); _setMetadata(ipMetadata, ipId); - ISPGNFT(nftContract).safeTransferFrom(address(this), recipient, tokenId, ""); + ISPGNFT(spgNftContract).safeTransferFrom(address(this), recipient, tokenId, ""); } /// @notice Registers an NFT as IP with metadata. @@ -187,7 +201,7 @@ contract StoryProtocolGateway is /// @param tokenId The ID of the NFT. /// @param ipMetadata OPTIONAL. The desired metadata for the newly registered IP. /// @param sigMetadata OPTIONAL. Signature data for setAll (metadata) for the IP via the Core Metadata Module. - /// @return ipId The ID of the registered IP. + /// @return ipId The ID of the newly registered IP function registerIp( address nftContract, uint256 tokenId, @@ -201,7 +215,7 @@ contract StoryProtocolGateway is /// @notice Register Programmable IP License Terms (if unregistered) and attach it to IP. /// @param ipId The ID of the IP. /// @param terms The PIL terms to be registered. - /// @return licenseTermsId The ID of the registered PIL terms. + /// @return licenseTermsId The ID of the newly registered PIL terms. function registerPILTermsAndAttach( address ipId, PILTerms calldata terms @@ -209,32 +223,37 @@ contract StoryProtocolGateway is licenseTermsId = _registerPILTermsAndAttach(ipId, terms); } - /// @notice Mint an NFT from a collection, register it with metadata as an IP, register Programmable IP License + /// @notice Mint an NFT from a SPGNFT collection, register it with metadata as an IP, + /// register Programmable IP License /// Terms (if unregistered), and attach it to the registered IP. /// @dev Caller must have the minter role for the provided SPG NFT. - /// @param nftContract The address of the NFT collection. + /// @param spgNftContract The address of the SPGNFT collection. /// @param recipient The address of the recipient of the minted NFT. /// @param ipMetadata OPTIONAL. The desired metadata for the newly minted NFT and registered IP. /// @param terms The PIL terms to be registered. - /// @return ipId The ID of the registered IP. - /// @return tokenId The ID of the minted NFT. - /// @return licenseTermsId The ID of the registered PIL terms. + /// @return ipId The ID of the newly registered IP. + /// @return tokenId The ID of the newly minted NFT. + /// @return licenseTermsId The ID of the newly registered PIL terms. function mintAndRegisterIpAndAttachPILTerms( - address nftContract, + address spgNftContract, address recipient, IPMetadata calldata ipMetadata, PILTerms calldata terms - ) external onlyCallerWithMinterRole(nftContract) returns (address ipId, uint256 tokenId, uint256 licenseTermsId) { - tokenId = ISPGNFT(nftContract).mintBySPG({ + ) + external + onlyCallerWithMinterRole(spgNftContract) + returns (address ipId, uint256 tokenId, uint256 licenseTermsId) + { + tokenId = ISPGNFT(spgNftContract).mintBySPG({ to: address(this), payer: msg.sender, nftMetadataURI: ipMetadata.nftMetadataURI }); - ipId = IP_ASSET_REGISTRY.register(block.chainid, nftContract, tokenId); + ipId = IP_ASSET_REGISTRY.register(block.chainid, spgNftContract, tokenId); _setMetadata(ipMetadata, ipId); licenseTermsId = _registerPILTermsAndAttach(ipId, terms); - ISPGNFT(nftContract).safeTransferFrom(address(this), recipient, tokenId, ""); + ISPGNFT(spgNftContract).safeTransferFrom(address(this), recipient, tokenId, ""); } /// @notice Register a given NFT as an IP and attach Programmable IP License Terms. @@ -245,10 +264,9 @@ contract StoryProtocolGateway is /// @param ipMetadata OPTIONAL. The desired metadata for the newly registered IP. /// @param terms The PIL terms to be registered. /// @param sigMetadata OPTIONAL. Signature data for setAll (metadata) for the IP via the Core Metadata Module. - /// @param sigAttach Signature data for attachLicenseTerms to the IP via the Licensing Module. The nonce of this - /// signature must be one above `sigMetadata` if the metadata is being set, ie. `sigMetadata` is non-empty. - /// @return ipId The ID of the registered IP. - /// @return licenseTermsId The ID of the registered PIL terms. + /// @param sigAttach Signature data for attachLicenseTerms to the IP via the Licensing Module. + /// @return ipId The ID of the newly registered IP. + /// @return licenseTermsId The ID of the newly registered PIL terms. function registerIpAndAttachPILTerms( address nftContract, uint256 tokenId, @@ -268,33 +286,33 @@ contract StoryProtocolGateway is licenseTermsId = _registerPILTermsAndAttach(ipId, terms); } - /// @notice Mint an NFT from a collection and register it as a derivative IP without license tokens. + /// @notice Mint an NFT from a SPGNFT collection and register it as a derivative IP without license tokens. /// @dev Caller must have the minter role for the provided SPG NFT. - /// @param nftContract The address of the NFT collection. + /// @param spgNftContract The address of the SPGNFT collection. /// @param derivData The derivative data to be used for registerDerivative. /// @param ipMetadata OPTIONAL. The desired metadata for the newly minted NFT and registered IP. /// @param recipient The address to receive the minted NFT. - /// @return ipId The ID of the registered IP. - /// @return tokenId The ID of the minted NFT. + /// @return ipId The ID of the newly registered IP. + /// @return tokenId The ID of the newly minted NFT. function mintAndRegisterIpAndMakeDerivative( - address nftContract, + address spgNftContract, MakeDerivative calldata derivData, IPMetadata calldata ipMetadata, address recipient - ) external onlyCallerWithMinterRole(nftContract) returns (address ipId, uint256 tokenId) { - tokenId = ISPGNFT(nftContract).mintBySPG({ + ) external onlyCallerWithMinterRole(spgNftContract) returns (address ipId, uint256 tokenId) { + tokenId = ISPGNFT(spgNftContract).mintBySPG({ to: address(this), payer: msg.sender, nftMetadataURI: ipMetadata.nftMetadataURI }); - ipId = IP_ASSET_REGISTRY.register(block.chainid, nftContract, tokenId); + ipId = IP_ASSET_REGISTRY.register(block.chainid, spgNftContract, tokenId); _setMetadata(ipMetadata, ipId); _collectMintFeesAndSetApproval( msg.sender, ipId, - derivData.parentIpIds, derivData.licenseTemplate, + derivData.parentIpIds, derivData.licenseTermsIds ); @@ -306,17 +324,17 @@ contract StoryProtocolGateway is royaltyContext: derivData.royaltyContext }); - ISPGNFT(nftContract).safeTransferFrom(address(this), recipient, tokenId, ""); + ISPGNFT(spgNftContract).safeTransferFrom(address(this), recipient, tokenId, ""); } - /// @notice Register the given NFT as a derivative IP with metadata without using license tokens. + /// @notice Register the given NFT as a derivative IP with metadata without license tokens. /// @param nftContract The address of the NFT collection. /// @param tokenId The ID of the NFT. /// @param derivData The derivative data to be used for registerDerivative. /// @param ipMetadata OPTIONAL. The desired metadata for the newly registered IP. /// @param sigMetadata OPTIONAL. Signature data for setAll (metadata) for the IP via the Core Metadata Module. /// @param sigRegister Signature data for registerDerivative for the IP via the Licensing Module. - /// @return ipId The ID of the registered IP. + /// @return ipId The ID of the newly registered IP. function registerIpAndMakeDerivative( address nftContract, uint256 tokenId, @@ -337,8 +355,8 @@ contract StoryProtocolGateway is _collectMintFeesAndSetApproval( msg.sender, ipId, - derivData.parentIpIds, derivData.licenseTemplate, + derivData.parentIpIds, derivData.licenseTermsIds ); @@ -354,7 +372,7 @@ contract StoryProtocolGateway is /// @notice Mint an NFT from a collection and register it as a derivative IP using license tokens /// @dev Caller must have the minter role for the provided SPG NFT. Caller must own the license tokens and have /// approved SPG to transfer them. - /// @param nftContract The address of the NFT collection. + /// @param spgNftContract The address of the NFT collection. /// @param licenseTokenIds The IDs of the license tokens to be burned for linking the IP to parent IPs. /// @param royaltyContext The context for royalty module, should be empty for Royalty Policy LAP. /// @param ipMetadata OPTIONAL. The desired metadata for the newly minted NFT and newly registered IP. @@ -362,25 +380,25 @@ contract StoryProtocolGateway is /// @return ipId The ID of the registered IP. /// @return tokenId The ID of the minted NFT. function mintAndRegisterIpAndMakeDerivativeWithLicenseTokens( - address nftContract, + address spgNftContract, uint256[] calldata licenseTokenIds, bytes calldata royaltyContext, IPMetadata calldata ipMetadata, address recipient - ) external onlyCallerWithMinterRole(nftContract) returns (address ipId, uint256 tokenId) { + ) external onlyCallerWithMinterRole(spgNftContract) returns (address ipId, uint256 tokenId) { _collectLicenseTokens(licenseTokenIds); - tokenId = ISPGNFT(nftContract).mintBySPG({ + tokenId = ISPGNFT(spgNftContract).mintBySPG({ to: address(this), payer: msg.sender, nftMetadataURI: ipMetadata.nftMetadataURI }); - ipId = IP_ASSET_REGISTRY.register(block.chainid, nftContract, tokenId); + ipId = IP_ASSET_REGISTRY.register(block.chainid, spgNftContract, tokenId); _setMetadata(ipMetadata, ipId); LICENSING_MODULE.registerDerivativeWithLicenseTokens(ipId, licenseTokenIds, royaltyContext); - ISPGNFT(nftContract).safeTransferFrom(address(this), recipient, tokenId, ""); + ISPGNFT(spgNftContract).safeTransferFrom(address(this), recipient, tokenId, ""); } /// @notice Register the given NFT as a derivative IP using license tokens. @@ -392,7 +410,7 @@ contract StoryProtocolGateway is /// @param ipMetadata OPTIONAL. The desired metadata for the newly registered IP. /// @param sigMetadata OPTIONAL. Signature data for setAll (metadata) for the IP via the Core Metadata Module. /// @param sigRegister Signature data for registerDerivativeWithLicenseTokens for the IP via the Licensing Module. - /// @return ipId The ID of the registered IP. + /// @return ipId The ID of the newly registered IP. function registerIpAndMakeDerivativeWithLicenseTokens( address nftContract, uint256 tokenId, @@ -415,6 +433,108 @@ contract StoryProtocolGateway is LICENSING_MODULE.registerDerivativeWithLicenseTokens(ipId, licenseTokenIds, royaltyContext); } + /// @notice Mint an NFT from a SPGNFT collection, register it with metadata as an IP, attach + /// Programmable IP License Terms to the registered IP, and add it to a group IP. + /// @dev Caller must have the minter role for the provided SPG NFT. + /// @param spgNftContract The address of the SPGNFT collection. + /// @param groupId The ID of the group IP to add the newly registered IP. + /// @param recipient The address of the recipient of the minted NFT. + /// @param licenseTermsId The ID of the registered PIL terms that will be attached to the newly registered IP. + /// @param ipMetadata OPTIONAL. The desired metadata for the newly minted NFT and registered IP. + /// @param sigAddToGroup Signature data for addIp to the group IP via the Grouping Module. + /// @return ipId The ID of the newly registered IP. + /// @return tokenId The ID of the newly minted NFT. + function mintAndRegisterIpAndAttachPILTermsAndAddToGroup( + address spgNftContract, + address groupId, + address recipient, + uint256 licenseTermsId, + IPMetadata calldata ipMetadata, + SignatureData calldata sigAddToGroup + ) external onlyCallerWithMinterRole(spgNftContract) returns (address ipId, uint256 tokenId) { + tokenId = ISPGNFT(spgNftContract).mintBySPG({ + to: address(this), + payer: msg.sender, + nftMetadataURI: ipMetadata.nftMetadataURI + }); + ipId = IP_ASSET_REGISTRY.register(block.chainid, spgNftContract, tokenId); + _setMetadata(ipMetadata, ipId); + + LICENSING_MODULE.attachLicenseTerms(ipId, address(PIL_TEMPLATE), licenseTermsId); + + _setPermissionForModule(groupId, sigAddToGroup, address(GROUPING_MODULE), IGroupingModule.addIp.selector); + + address[] memory ipIds = new address[](1); + ipIds[0] = ipId; + GROUPING_MODULE.addIp(groupId, ipIds); + + ISPGNFT(spgNftContract).safeTransferFrom(address(this), recipient, tokenId, ""); + } + + /// @notice Register an NFT as IP with metadata, attach Programmable IP License Terms to the registered IP, + /// and add it to a group IP. + /// @param nftContract The address of the NFT collection. + /// @param tokenId The ID of the NFT. + /// @param groupId The ID of the group IP to add the newly registered IP. + /// @param licenseTermsId The ID of the registered PIL terms that will be attached to the newly registered IP. + /// @param ipMetadata OPTIONAL. The desired metadata for the newly registered IP. + /// @param sigMetadataAndAttach Signature data for setAll (metadata) and attachLicenseTerms to the IP + /// via the Core Metadata Module and Licensing Module. + /// @param sigAddToGroup Signature data for addIp to the group IP via the Grouping Module. + /// @return ipId The ID of the newly registered IP. + function registerIpAndAttachPILTermsAndAddToGroup( + address nftContract, + uint256 tokenId, + address groupId, + uint256 licenseTermsId, + IPMetadata calldata ipMetadata, + SignatureData calldata sigMetadataAndAttach, + SignatureData calldata sigAddToGroup + ) external returns (address ipId) { + ipId = IP_ASSET_REGISTRY.register(block.chainid, nftContract, tokenId); + + address[] memory modules = new address[](2); + bytes4[] memory selectors = new bytes4[](2); + modules[0] = address(CORE_METADATA_MODULE); + modules[1] = address(LICENSING_MODULE); + selectors[0] = ICoreMetadataModule.setAll.selector; + selectors[1] = ILicensingModule.attachLicenseTerms.selector; + + _setBatchPermissionForModules(ipId, sigMetadataAndAttach, modules, selectors); + + _setMetadata(ipMetadata, ipId); + + LICENSING_MODULE.attachLicenseTerms(ipId, address(PIL_TEMPLATE), licenseTermsId); + + _setPermissionForModule(groupId, sigAddToGroup, address(GROUPING_MODULE), IGroupingModule.addIp.selector); + + address[] memory ipIds = new address[](1); + ipIds[0] = ipId; + GROUPING_MODULE.addIp(groupId, ipIds); + } + + /// @notice Register a group IP with a group reward pool, register Programmable IP License Terms, + /// attach it to the group IP, and add individual IPs to the group IP. + /// @dev ipIds must be have the same PIL terms as the group IP. + /// @param groupPool The address of the group reward pool. + /// @param ipIds The IDs of the IPs to add to the newly registered group IP. + /// @param groupIpTerms The PIL terms to be registered and attached to the newly registered group IP. + /// @return groupId The ID of the newly registered group IP. + /// @return groupLicenseTermsId The ID of the newly registered PIL terms. + function registerGroupAndAttachPILTermsAndAddIps( + address groupPool, + address[] calldata ipIds, + PILTerms calldata groupIpTerms + ) external returns (address groupId, uint256 groupLicenseTermsId) { + groupId = GROUPING_MODULE.registerGroup(groupPool); + + groupLicenseTermsId = _registerPILTermsAndAttach(groupId, groupIpTerms); + + GROUPING_MODULE.addIp(groupId, ipIds); + + GROUP_NFT.safeTransferFrom(address(this), msg.sender, GROUP_NFT.totalSupply() - 1); + } + /// @dev Registers PIL License Terms and attaches them to the given IP. /// @param ipId The ID of the IP. /// @param terms The PIL terms to be registered. @@ -461,6 +581,40 @@ contract StoryProtocolGateway is ); } + /// @dev Sets batch permission via signature to allow this contract to interact with mutiple modules + /// on behalf of the provided IP Account. + /// @param ipId The ID of the IP. + /// @param sigData Signature data for setting the batch permission. + /// @param modules The addresses of the modules to set the permission for. + /// @param selectors The selectors of the functions to be permitted for execution. + function _setBatchPermissionForModules( + address ipId, + SignatureData calldata sigData, + address[] memory modules, + bytes4[] memory selectors + ) internal { + // assumes modules and selectors must have a 1:1 mapping + AccessPermission.Permission[] memory permissionList = new AccessPermission.Permission[](modules.length); + for (uint256 i = 0; i < modules.length; i++) { + permissionList[i] = AccessPermission.Permission({ + ipAccount: ipId, + signer: address(this), + to: modules[i], + func: selectors[i], + permission: AccessPermission.ALLOW + }); + } + + IIPAccount(payable(ipId)).executeWithSig( + address(ACCESS_CONTROLLER), + 0, + abi.encodeWithSelector(IAccessController.setBatchPermissions.selector, permissionList), + sigData.signer, + sigData.deadline, + sigData.signature + ); + } + /// @dev Sets the metadata for the given IP if metadata is non-empty. /// @dev Sets the metadata for the given IP if metadata is non-empty. /// @param ipMetadata The metadata to set. @@ -514,14 +668,14 @@ contract StoryProtocolGateway is /// @dev Collect mint fees for all parent IPs from the payer and set approval for Royalty Module to spend mint fees. /// @param payerAddress The address of the payer for the license mint fees. /// @param childIpId The ID of the derivative IP. - /// @param parentIpIds The IDs of all the parent IPs. /// @param licenseTemplate The address of the license template. + /// @param parentIpIds The IDs of all the parent IPs. /// @param licenseTermsIds The IDs of the license terms for each corresponding parent IP. function _collectMintFeesAndSetApproval( address payerAddress, address childIpId, - address[] calldata parentIpIds, address licenseTemplate, + address[] calldata parentIpIds, uint256[] calldata licenseTermsIds ) internal { // Get currency token and royalty policy, assumes all parent IPs have the same currency token. @@ -537,7 +691,7 @@ contract StoryProtocolGateway is IERC20(mintFeeCurrencyToken).safeTransferFrom(payerAddress, address(this), totalMintFee); // Approve Royalty Policy to spend mint fee - IERC20(mintFeeCurrencyToken).forceApprove(royaltyPolicy, totalMintFee); + IERC20(mintFeeCurrencyToken).forceApprove(address(ROYALTY_MODULE), totalMintFee); } } } diff --git a/contracts/interfaces/IStoryProtocolGateway.sol b/contracts/interfaces/IStoryProtocolGateway.sol index 002ec3e..235acdf 100644 --- a/contracts/interfaces/IStoryProtocolGateway.sol +++ b/contracts/interfaces/IStoryProtocolGateway.sol @@ -60,15 +60,15 @@ interface IStoryProtocolGateway { address owner ) external returns (address nftContract); - /// @notice Mint an NFT from a collection and register it with metadata as an IP. + /// @notice Mint an NFT from a SPGNFT collection and register it with metadata as an IP. /// @dev Caller must have the minter role for the provided SPG NFT. - /// @param nftContract The address of the NFT collection. + /// @param spgNftContract The address of the SPGNFT collection. /// @param recipient The address of the recipient of the minted NFT. /// @param ipMetadata OPTIONAL. The desired metadata for the newly minted NFT and registered IP. /// @return ipId The ID of the registered IP. - /// @return tokenId The ID of the minted NFT. + /// @return tokenId The ID of the newly minted NFT. function mintAndRegisterIp( - address nftContract, + address spgNftContract, address recipient, IPMetadata calldata ipMetadata ) external returns (address ipId, uint256 tokenId); @@ -78,7 +78,7 @@ interface IStoryProtocolGateway { /// @param tokenId The ID of the NFT. /// @param ipMetadata OPTIONAL. The desired metadata for the newly registered IP. /// @param sigMetadata OPTIONAL. Signature data for setAll (metadata) for the IP via the Core Metadata Module. - /// @return ipId The ID of the registered IP. + /// @return ipId The ID of the newly registered IP. function registerIp( address nftContract, uint256 tokenId, @@ -89,21 +89,22 @@ interface IStoryProtocolGateway { /// @notice Register Programmable IP License Terms (if unregistered) and attach it to IP. /// @param ipId The ID of the IP. /// @param terms The PIL terms to be registered. - /// @return licenseTermsId The ID of the registered PIL terms. + /// @return licenseTermsId The ID of the newly registered PIL terms. function registerPILTermsAndAttach(address ipId, PILTerms calldata terms) external returns (uint256 licenseTermsId); - /// @notice Mint an NFT from a collection, register it with metadata as an IP, register Programmable IP License + /// @notice Mint an NFT from a SPGNFT collection, register it with metadata as an IP, + /// register Programmable IPLicense /// Terms (if unregistered), and attach it to the registered IP. /// @dev Caller must have the minter role for the provided SPG NFT. - /// @param nftContract The address of the NFT collection. + /// @param spgNftContract The address of the SPGNFT collection. /// @param recipient The address of the recipient of the minted NFT. /// @param ipMetadata OPTIONAL. The desired metadata for the newly minted NFT and registered IP. /// @param terms The PIL terms to be registered. - /// @return ipId The ID of the registered IP. - /// @return tokenId The ID of the minted NFT. - /// @return licenseTermsId The ID of the registered PIL terms. + /// @return ipId The ID of the newly registered IP. + /// @return tokenId The ID of the newly minted NFT. + /// @return licenseTermsId The ID of the newly registered PIL terms. function mintAndRegisterIpAndAttachPILTerms( - address nftContract, + address spgNftContract, address recipient, IPMetadata calldata ipMetadata, PILTerms calldata terms @@ -118,8 +119,8 @@ interface IStoryProtocolGateway { /// @param terms The PIL terms to be registered. /// @param sigMetadata OPTIONAL. Signature data for setAll (metadata) for the IP via the Core Metadata Module. /// @param sigAttach Signature data for attachLicenseTerms to the IP via the Licensing Module. - /// @return ipId The ID of the registered IP. - /// @return licenseTermsId The ID of the registered PIL terms. + /// @return ipId The ID of the newly registered IP. + /// @return licenseTermsId The ID of the newly registered PIL terms. function registerIpAndAttachPILTerms( address nftContract, uint256 tokenId, @@ -129,29 +130,29 @@ interface IStoryProtocolGateway { SignatureData calldata sigAttach ) external returns (address ipId, uint256 licenseTermsId); - /// @notice Mint an NFT from a collection and register it as a derivative IP without license tokens. + /// @notice Mint an NFT from a SPGNFT collection and register it as a derivative IP without license tokens. /// @dev Caller must have the minter role for the provided SPG NFT. - /// @param nftContract The address of the NFT collection. + /// @param spgNftContract The address of the SPGNFT collection. /// @param derivData The derivative data to be used for registerDerivative. /// @param ipMetadata OPTIONAL. The desired metadata for the newly minted NFT and registered IP. /// @param recipient The address to receive the minted NFT. - /// @return ipId The ID of the registered IP. - /// @return tokenId The ID of the minted NFT. + /// @return ipId The ID of the newly registered IP. + /// @return tokenId The ID of the newly minted NFT. function mintAndRegisterIpAndMakeDerivative( - address nftContract, + address spgNftContract, MakeDerivative calldata derivData, IPMetadata calldata ipMetadata, address recipient ) external returns (address ipId, uint256 tokenId); - /// @notice Register the given NFT as a derivative IP with metadata without using license tokens. + /// @notice Register the given NFT as a derivative IP with metadata without license tokens. /// @param nftContract The address of the NFT collection. /// @param tokenId The ID of the NFT. /// @param derivData The derivative data to be used for registerDerivative. /// @param ipMetadata OPTIONAL. The desired metadata for the newly registered IP. /// @param sigMetadata OPTIONAL. Signature data for setAll (metadata) for the IP via the Core Metadata Module. /// @param sigRegister Signature data for registerDerivative for the IP via the Licensing Module. - /// @return ipId The ID of the registered IP. + /// @return ipId The ID of the newly registered IP. function registerIpAndMakeDerivative( address nftContract, uint256 tokenId, @@ -161,17 +162,17 @@ interface IStoryProtocolGateway { SignatureData calldata sigRegister ) external returns (address ipId); - /// @notice Mint an NFT from a collection and register it as a derivative IP using license tokens. + /// @notice Mint an NFT from a SPGNFT collection and register it as a derivative IP using license tokens. /// @dev Caller must have the minter role for the provided SPG NFT. - /// @param nftContract The address of the NFT collection. + /// @param spgNftContract The address of the SPGNFT collection. /// @param licenseTokenIds The IDs of the license tokens to be burned for linking the IP to parent IPs. /// @param royaltyContext The context for royalty module, should be empty for Royalty Policy LAP. /// @param ipMetadata OPTIONAL. The desired metadata for the newly minted NFT and registered IP. /// @param recipient The address to receive the minted NFT. - /// @return ipId The ID of the registered IP. - /// @return tokenId The ID of the minted NFT. + /// @return ipId The ID of the newly registered IP. + /// @return tokenId The ID of the newly minted NFT. function mintAndRegisterIpAndMakeDerivativeWithLicenseTokens( - address nftContract, + address spgNftContract, uint256[] calldata licenseTokenIds, bytes calldata royaltyContext, IPMetadata calldata ipMetadata, @@ -186,7 +187,7 @@ interface IStoryProtocolGateway { /// @param ipMetadata OPTIONAL. The desired metadata for the newly registered IP. /// @param sigMetadata OPTIONAL. Signature data for setAll (metadata) for the IP via the Core Metadata Module. /// @param sigRegister Signature data for registerDerivativeWithLicenseTokens for the IP via the Licensing Module. - /// @return ipId The ID of the registered IP. + /// @return ipId The ID of the newly registered IP. function registerIpAndMakeDerivativeWithLicenseTokens( address nftContract, uint256 tokenId, @@ -196,4 +197,59 @@ interface IStoryProtocolGateway { SignatureData calldata sigMetadata, SignatureData calldata sigRegister ) external returns (address ipId); + + /// @notice Mint an NFT from a SPGNFT collection, register it with metadata as an IP, + /// attach Programmable IP License Terms to the registered IP, and add it to a group IP. + /// @dev Caller must have the minter role for the provided SPG NFT. + /// @param spgNftContract The address of the SPGNFT collection. + /// @param groupId The ID of the group IP to add the newly registered IP. + /// @param recipient The address of the recipient of the minted NFT. + /// @param licenseTermsId The ID of the registered PIL terms that will be attached to the newly registered IP. + /// @param ipMetadata OPTIONAL. The desired metadata for the newly minted NFT and registered IP. + /// @param sigAddToGroup Signature data for addIp to the group IP via the Grouping Module. + /// @return ipId The ID of the newly registered IP. + /// @return tokenId The ID of the newly minted NFT. + function mintAndRegisterIpAndAttachPILTermsAndAddToGroup( + address spgNftContract, + address groupId, + address recipient, + uint256 licenseTermsId, + IPMetadata calldata ipMetadata, + SignatureData calldata sigAddToGroup + ) external returns (address ipId, uint256 tokenId); + + /// @notice Register an NFT as IP with metadata, attach Programmable IP License Terms to the registered IP, + /// and add it to a group IP. + /// @param nftContract The address of the NFT collection. + /// @param tokenId The ID of the NFT. + /// @param groupId The ID of the group IP to add the newly registered IP. + /// @param licenseTermsId The ID of the registered PIL terms that will be attached to the newly registered IP. + /// @param ipMetadata OPTIONAL. The desired metadata for the newly registered IP. + /// @param sigMetadataAndAttach Signature data for setAll (metadata) and attachLicenseTerms to the IP + /// via the Core Metadata Module and Licensing Module. + /// @param sigAddToGroup Signature data for addIp to the group IP via the Grouping Module. + /// @return ipId The ID of the newly registered IP. + function registerIpAndAttachPILTermsAndAddToGroup( + address nftContract, + uint256 tokenId, + address groupId, + uint256 licenseTermsId, + IPMetadata calldata ipMetadata, + SignatureData calldata sigMetadataAndAttach, + SignatureData calldata sigAddToGroup + ) external returns (address ipId); + + /// @notice Register a group IP with a group reward pool, register Programmable IP License Terms, + /// attach it to the group IP, and add individual IPs to the group IP. + /// @dev ipIds must be have the same PIL terms as the group IP. + /// @param groupPool The address of the group reward pool. + /// @param ipIds The IDs of the IPs to add to the newly registered group IP. + /// @param groupIpTerms The PIL terms to be registered and attached to the newly registered group IP. + /// @return groupId The ID of the newly registered group IP. + /// @return groupLicenseTermsId The ID of the newly registered PIL terms. + function registerGroupAndAttachPILTermsAndAddIps( + address groupPool, + address[] calldata ipIds, + PILTerms calldata groupIpTerms + ) external returns (address groupId, uint256 groupLicenseTermsId); } diff --git a/script/Main.s.sol b/script/Main.s.sol index e46322a..35a458d 100644 --- a/script/Main.s.sol +++ b/script/Main.s.sol @@ -4,11 +4,9 @@ pragma solidity ^0.8.23; import { console2 } from "forge-std/console2.sol"; import { Script } from "forge-std/Script.sol"; -import { stdJson } from "forge-std/StdJson.sol"; import { ICreate3Deployer } from "@create3-deployer/contracts/Create3Deployer.sol"; import { UpgradeableBeacon } from "@openzeppelin/contracts/proxy/beacon/UpgradeableBeacon.sol"; -import { IPAssetRegistry } from "@storyprotocol/core/registries/IPAssetRegistry.sol"; import { StoryProtocolGateway } from "../contracts/StoryProtocolGateway.sol"; import { SPGNFT } from "../contracts/SPGNFT.sol"; @@ -61,7 +59,9 @@ contract Main is Script, StoryProtocolCoreAddressManager, BroadcastManager, Json royaltyModuleAddr, coreMetadataModuleAddr, pilTemplateAddr, - licenseTokenAddr + licenseTokenAddr, + groupingModuleAddr, + groupNFTAddr ) ); spg = StoryProtocolGateway( @@ -103,7 +103,7 @@ contract Main is Script, StoryProtocolCoreAddressManager, BroadcastManager, Json console2.log(string.concat(contractKey, " deployed to:"), newAddress); } - function _getSalt(string memory name) private view returns (bytes32 salt) { + function _getSalt(string memory name) private pure returns (bytes32 salt) { salt = keccak256(abi.encode(name, create3SaltSeed)); } } diff --git a/script/UpgradeSPG.s.sol b/script/UpgradeSPG.s.sol index 4031dfd..85b0db7 100644 --- a/script/UpgradeSPG.s.sol +++ b/script/UpgradeSPG.s.sol @@ -60,7 +60,9 @@ contract UpgradeSPG is royaltyModuleAddr, coreMetadataModuleAddr, pilTemplateAddr, - licenseTokenAddr + licenseTokenAddr, + groupingModuleAddr, + groupNFTAddr ) ); console2.log("New SPG Implementation", newSpgImpl); diff --git a/script/utils/StoryProtocolCoreAddressManager.sol b/script/utils/StoryProtocolCoreAddressManager.sol index b0c4ba5..13ba184 100644 --- a/script/utils/StoryProtocolCoreAddressManager.sol +++ b/script/utils/StoryProtocolCoreAddressManager.sol @@ -16,6 +16,8 @@ contract StoryProtocolCoreAddressManager is Script { address internal accessControllerAddr; address internal pilTemplateAddr; address internal licenseTokenAddr; + address internal groupingModuleAddr; + address internal groupNFTAddr; function _readStoryProtocolCoreAddresses() internal { string memory root = vm.projectRoot(); @@ -39,5 +41,7 @@ contract StoryProtocolCoreAddressManager is Script { accessControllerAddr = json.readAddress(".main.AccessController"); pilTemplateAddr = json.readAddress(".main.PILicenseTemplate"); licenseTokenAddr = json.readAddress(".main.LicenseToken"); + groupingModuleAddr = json.readAddress(".main.GroupingModule"); + groupNFTAddr = json.readAddress(".main.GroupNFT"); } } diff --git a/test/StoryProtocolGateway.t.sol b/test/StoryProtocolGateway.t.sol index 508c28b..a6664a3 100644 --- a/test/StoryProtocolGateway.t.sol +++ b/test/StoryProtocolGateway.t.sol @@ -7,6 +7,8 @@ import { PILFlavors } from "@storyprotocol/core/lib/PILFlavors.sol"; import { MetaTx } from "@storyprotocol/core/lib/MetaTx.sol"; import { ILicensingModule } from "@storyprotocol/core/interfaces/modules/licensing/ILicensingModule.sol"; import { ICoreMetadataModule } from "@storyprotocol/core/interfaces/modules/metadata/ICoreMetadataModule.sol"; +import { IGroupingModule } from "@storyprotocol/core/interfaces/modules/grouping/IGroupingModule.sol"; +import { IAccessController } from "@storyprotocol/core/interfaces/access/IAccessController.sol"; import { IStoryProtocolGateway as ISPG } from "../contracts/interfaces/IStoryProtocolGateway.sol"; import { ISPGNFT } from "../contracts/interfaces/ISPGNFT.sol"; @@ -25,6 +27,7 @@ contract StoryProtocolGatewayTest is BaseTest { ISPGNFT internal nftContract; ISPGNFT[] internal nftContracts; + address internal groupId; address internal minter; address internal caller; mapping(uint256 index => IPAsset) internal ipAsset; @@ -87,7 +90,7 @@ contract StoryProtocolGatewayTest is BaseTest { { vm.expectRevert(Errors.SPG__CallerNotMinterRole.selector); vm.prank(caller); - spg.mintAndRegisterIp({ nftContract: address(nftContract), recipient: bob, ipMetadata: ipMetadataEmpty }); + spg.mintAndRegisterIp({ spgNftContract: address(nftContract), recipient: bob, ipMetadata: ipMetadataEmpty }); } modifier whenCallerHasMinterRole() { @@ -101,7 +104,7 @@ contract StoryProtocolGatewayTest is BaseTest { mockToken.approve(address(nftContract), 1000 * 10 ** mockToken.decimals()); (address ipId1, uint256 tokenId1) = spg.mintAndRegisterIp({ - nftContract: address(nftContract), + spgNftContract: address(nftContract), recipient: bob, ipMetadata: ipMetadataEmpty }); @@ -111,7 +114,7 @@ contract StoryProtocolGatewayTest is BaseTest { assertMetadata(ipId1, ipMetadataEmpty); (address ipId2, uint256 tokenId2) = spg.mintAndRegisterIp({ - nftContract: address(nftContract), + spgNftContract: address(nftContract), recipient: bob, ipMetadata: ipMetadataDefault }); @@ -155,7 +158,7 @@ contract StoryProtocolGatewayTest is BaseTest { mockToken.mint(address(owner), 100 * 10 ** mockToken.decimals()); mockToken.approve(address(nftContract), 100 * 10 ** mockToken.decimals()); (address ipId, uint256 tokenId) = spg.mintAndRegisterIp({ - nftContract: address(nftContract), + spgNftContract: address(nftContract), recipient: owner, ipMetadata: ipMetadataDefault }); @@ -212,7 +215,7 @@ contract StoryProtocolGatewayTest is BaseTest { withEnoughTokens { (address ipId1, uint256 tokenId1, uint256 licenseTermsId1) = spg.mintAndRegisterIpAndAttachPILTerms({ - nftContract: address(nftContract), + spgNftContract: address(nftContract), recipient: caller, ipMetadata: ipMetadataEmpty, terms: PILFlavors.nonCommercialSocialRemixing() @@ -227,7 +230,7 @@ contract StoryProtocolGatewayTest is BaseTest { assertEq(licenseTermsId, licenseTermsId1); (address ipId2, uint256 tokenId2, uint256 licenseTermsId2) = spg.mintAndRegisterIpAndAttachPILTerms({ - nftContract: address(nftContract), + spgNftContract: address(nftContract), recipient: caller, ipMetadata: ipMetadataDefault, terms: PILFlavors.nonCommercialSocialRemixing() @@ -275,7 +278,7 @@ contract StoryProtocolGatewayTest is BaseTest { modifier withNonCommercialParentIp() { (ipIdParent, , ) = spg.mintAndRegisterIpAndAttachPILTerms({ - nftContract: address(nftContract), + spgNftContract: address(nftContract), recipient: caller, ipMetadata: ipMetadataDefault, terms: PILFlavors.nonCommercialSocialRemixing() @@ -305,7 +308,7 @@ contract StoryProtocolGatewayTest is BaseTest { modifier withCommercialParentIp() { (ipIdParent, , ) = spg.mintAndRegisterIpAndAttachPILTerms({ - nftContract: address(nftContract), + spgNftContract: address(nftContract), recipient: caller, ipMetadata: ipMetadataDefault, terms: PILFlavors.commercialUse({ @@ -365,7 +368,7 @@ contract StoryProtocolGatewayTest is BaseTest { licenseTokenIds[0] = startLicenseTokenId; (address ipIdChild, uint256 tokenIdChild) = spg.mintAndRegisterIpAndMakeDerivativeWithLicenseTokens({ - nftContract: address(nftContract), + spgNftContract: address(nftContract), licenseTokenIds: licenseTokenIds, royaltyContext: "", ipMetadata: ipMetadataDefault, @@ -579,6 +582,170 @@ contract StoryProtocolGatewayTest is BaseTest { } } + modifier withGroup() { + groupId = groupingModule.registerGroup(address(rewardPool)); + uint256 deadline = block.timestamp + 1000; + + (bytes memory signature, , bytes memory data) = _getSetPermissionSignatureForSPG({ + ipId: groupId, + module: address(licensingModule), + selector: ILicensingModule.attachLicenseTerms.selector, + deadline: deadline, + state: IIPAccount(payable(groupId)).state(), + signerPk: alicePk + }); + + IIPAccount(payable(groupId)).executeWithSig({ + to: address(accessController), + value: 0, + data: data, + signer: alice, + deadline: deadline, + signature: signature + }); + + uint256 licenseTermsId = spg.registerPILTermsAndAttach({ + ipId: groupId, + terms: PILFlavors.nonCommercialSocialRemixing() + }); + _; + } + + function test_SPG_mintAndRegisterIpAndAddToGroup() + public + withCollection + whenCallerHasMinterRole + withEnoughTokens + withGroup + { + uint256 deadline = block.timestamp + 1000; + + (bytes memory sigAddToGroup, bytes32 expectedState, ) = _getSetPermissionSignatureForSPG({ + ipId: groupId, + module: address(groupingModule), + selector: IGroupingModule.addIp.selector, + deadline: deadline, + state: IIPAccount(payable(groupId)).state(), + signerPk: alicePk + }); + + (address ipId, uint256 tokenId) = spg.mintAndRegisterIpAndAttachPILTermsAndAddToGroup({ + spgNftContract: address(nftContract), + groupId: groupId, + recipient: caller, + ipMetadata: ipMetadataEmpty, + licenseTermsId: 1, + sigAddToGroup: ISPG.SignatureData({ signer: alice, deadline: deadline, signature: sigAddToGroup }) + }); + + assertEq(expectedState, IIPAccount(payable(groupId)).state()); + assertTrue(ipAssetRegistry.isRegistered(ipId)); + assertTrue(ipAssetRegistry.isRegisteredGroup(groupId)); + assertTrue(ipAssetRegistry.containsIp(groupId, ipId)); + assertEq(ipAssetRegistry.totalMembers(groupId), 1); + assertEq(tokenId, 1); + assertSPGNFTMetadata(tokenId, ipMetadataEmpty.nftMetadataURI); + assertMetadata(ipId, ipMetadataEmpty); + (address licenseTemplate, uint256 licenseTermsId) = licenseRegistry.getAttachedLicenseTerms(ipId, 0); + assertEq(licenseTemplate, address(pilTemplate)); + assertEq(licenseTermsId, 1); + } + + function test_SPG_registerIpAndAddToGroup() + public + withCollection + whenCallerHasMinterRole + withEnoughTokens + withGroup + { + uint256 tokenId = nftContract.mint(address(caller), ipMetadataEmpty.nftMetadataURI); + address expectedIpId = ipAssetRegistry.ipId(block.chainid, address(nftContract), tokenId); + + uint256 deadline = block.timestamp + 1000; + + (bytes memory sigMetadataAndAttach, , ) = _getSetBatchPermissionSignatureForSPG({ + ipId: expectedIpId, + permissionList: _getMetadataAndAttachTermsPermissionList(expectedIpId), + deadline: deadline, + state: bytes32(0), + signerPk: alicePk + }); + + (bytes memory sigAddToGroup, , ) = _getSetPermissionSignatureForSPG({ + ipId: groupId, + module: address(groupingModule), + selector: IGroupingModule.addIp.selector, + deadline: deadline, + state: IIPAccount(payable(groupId)).state(), + signerPk: alicePk + }); + + address ipId = spg.registerIpAndAttachPILTermsAndAddToGroup({ + nftContract: address(nftContract), + tokenId: tokenId, + groupId: groupId, + ipMetadata: ipMetadataEmpty, + licenseTermsId: 1, + sigMetadataAndAttach: ISPG.SignatureData({ + signer: alice, + deadline: deadline, + signature: sigMetadataAndAttach + }), + sigAddToGroup: ISPG.SignatureData({ signer: alice, deadline: deadline, signature: sigAddToGroup }) + }); + + assertEq(expectedIpId, ipId); + assertTrue(ipAssetRegistry.isRegistered(expectedIpId)); + assertTrue(ipAssetRegistry.isRegisteredGroup(groupId)); + assertTrue(ipAssetRegistry.containsIp(groupId, expectedIpId)); + assertEq(ipAssetRegistry.totalMembers(groupId), 1); + assertSPGNFTMetadata(tokenId, ipMetadataEmpty.nftMetadataURI); + assertMetadata(expectedIpId, ipMetadataEmpty); + (address licenseTemplate, uint256 licenseTermsId) = licenseRegistry.getAttachedLicenseTerms(expectedIpId, 0); + assertEq(licenseTemplate, address(pilTemplate)); + assertEq(licenseTermsId, 1); + } + + function test_SPG_registerGroupAndAddIps() public withCollection whenCallerHasMinterRole { + mockToken.mint(address(caller), 1000 * 10 * 10 ** mockToken.decimals()); + mockToken.approve(address(nftContract), 1000 * 10 * 10 ** mockToken.decimals()); + + bytes[] memory data = new bytes[](10); + for (uint256 i = 0; i < 10; i++) { + data[i] = abi.encodeWithSelector( + spg.mintAndRegisterIpAndAttachPILTerms.selector, + address(nftContract), + bob, + ipMetadataDefault, + PILFlavors.nonCommercialSocialRemixing() + ); + } + bytes[] memory results = spg.multicall(data); + address[] memory ipIds = new address[](10); + + for (uint256 i = 0; i < 10; i++) { + (ipIds[i], ) = abi.decode(results[i], (address, uint256)); + } + + uint256 groupLicenseTermsId; + (groupId, groupLicenseTermsId) = spg.registerGroupAndAttachPILTermsAndAddIps( + address(rewardPool), + ipIds, + PILFlavors.nonCommercialSocialRemixing() + ); + + assertTrue(ipAssetRegistry.isRegisteredGroup(groupId)); + assertEq(groupLicenseTermsId, 1); + (address licenseTemplate, uint256 licenseTermsId) = licenseRegistry.getAttachedLicenseTerms(groupId, 0); + assertEq(licenseTemplate, address(pilTemplate)); + assertEq(licenseTermsId, groupLicenseTermsId); + + assertEq(ipAssetRegistry.totalMembers(groupId), 10); + for (uint256 i = 0; i < 10; i++) { + assertTrue(ipAssetRegistry.containsIp(groupId, ipIds[i])); + } + } + /// @dev Assert metadata for the SPGNFT. function assertSPGNFTMetadata(uint256 tokenId, string memory expectedMetadata) internal { assertEq(nftContract.tokenURI(tokenId), expectedMetadata); @@ -613,6 +780,8 @@ contract StoryProtocolGatewayTest is BaseTest { /// @param state IPAccount's internal nonce /// @param signerPk The private key of the signer. /// @return signature The signature for setting the permission. + /// @return expectedState The expected IPAccount's state after setting the permission. + /// @return data The call data for executing the setPermission function. function _getSetPermissionSignatureForSPG( address ipId, address module, @@ -624,12 +793,12 @@ contract StoryProtocolGatewayTest is BaseTest { expectedState = keccak256( abi.encode( state, // ipAccount.state() - abi.encodeWithSignature( - "execute(address,uint256,bytes)", + abi.encodeWithSelector( + IIPAccount.execute.selector, address(accessController), 0, // amount of ether to send - abi.encodeWithSignature( - "setPermission(address,address,address,bytes4,uint8)", + abi.encodeWithSelector( + IAccessController.setPermission.selector, ipId, address(spg), address(module), @@ -640,8 +809,8 @@ contract StoryProtocolGatewayTest is BaseTest { ) ); - data = abi.encodeWithSignature( - "setPermission(address,address,address,bytes4,uint8)", + data = abi.encodeWithSelector( + IAccessController.setPermission.selector, ipId, address(spg), address(module), @@ -666,6 +835,80 @@ contract StoryProtocolGatewayTest is BaseTest { signature = abi.encodePacked(r, s, v); } + /// @dev Get the permission list for setting metadata and attaching license terms for the IP. + /// @param ipId The ID of the IP that the permissions are for. + /// @return permissionList The list of permissions for setting metadata and attaching license terms. + function _getMetadataAndAttachTermsPermissionList( + address ipId + ) internal view returns (AccessPermission.Permission[] memory permissionList) { + address[] memory modules = new address[](2); + bytes4[] memory selectors = new bytes4[](2); + permissionList = new AccessPermission.Permission[](modules.length); + + modules[0] = address(coreMetadataModule); + modules[1] = address(licensingModule); + selectors[0] = ICoreMetadataModule.setAll.selector; + selectors[1] = ILicensingModule.attachLicenseTerms.selector; + + for (uint256 i = 0; i < 2; i++) { + permissionList[i] = AccessPermission.Permission({ + ipAccount: ipId, + signer: address(spg), + to: modules[i], + func: selectors[i], + permission: AccessPermission.ALLOW + }); + } + } + + /// @dev Get the signature for setting batch permission for the IP by the SPG. + /// @param ipId The ID of the IP to set the permissions for. + /// @param permissionList A list of permissions to set. + /// @param deadline The deadline for the signature. + /// @param state IPAccount's internal state + /// @param signerPk The private key of the signer. + /// @return signature The signature for setting the batch permission. + /// @return expectedState The expected IPAccount's state after setting batch permission. + /// @return data The call data for executing the setBatchPermissions function. + function _getSetBatchPermissionSignatureForSPG( + address ipId, + AccessPermission.Permission[] memory permissionList, + uint256 deadline, + bytes32 state, + uint256 signerPk + ) internal returns (bytes memory signature, bytes32 expectedState, bytes memory data) { + assertEq(state, bytes32(0)); + expectedState = keccak256( + abi.encode( + state, // ipAccount.state() + abi.encodeWithSelector( + IIPAccount.execute.selector, + address(accessController), + 0, // amount of ether to send + abi.encodeWithSelector(IAccessController.setBatchPermissions.selector, permissionList) + ) + ) + ); + + data = abi.encodeWithSelector(IAccessController.setBatchPermissions.selector, permissionList); + + bytes32 digest = MessageHashUtils.toTypedDataHash( + MetaTx.calculateDomainSeparator(ipId), + MetaTx.getExecuteStructHash( + MetaTx.Execute({ + to: address(accessController), + value: 0, + data: data, + nonce: expectedState, + deadline: deadline + }) + ) + ); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(signerPk, digest); + signature = abi.encodePacked(r, s, v); + } + function _mintAndRegisterIpAndMakeDerivativeBaseTest() internal { (address licenseTemplateParent, uint256 licenseTermsIdParent) = licenseRegistry.getAttachedLicenseTerms( ipIdParent, @@ -679,7 +922,7 @@ contract StoryProtocolGatewayTest is BaseTest { licenseTermsIds[0] = licenseTermsIdParent; (address ipIdChild, uint256 tokenIdChild) = spg.mintAndRegisterIpAndMakeDerivative({ - nftContract: address(nftContract), + spgNftContract: address(nftContract), derivData: ISPG.MakeDerivative({ parentIpIds: parentIpIds, licenseTemplate: address(pilTemplate), diff --git a/test/mocks/MockEvenSplitGroupPool.sol b/test/mocks/MockEvenSplitGroupPool.sol new file mode 100644 index 0000000..7ca02ed --- /dev/null +++ b/test/mocks/MockEvenSplitGroupPool.sol @@ -0,0 +1,138 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.23; + +import { IGroupRewardPool } from "@storyprotocol/core/interfaces/modules/grouping/IGroupRewardPool.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import { EnumerableSet } from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; + +contract MockEvenSplitGroupPool is IGroupRewardPool { + using SafeERC20 for IERC20; + using EnumerableSet for EnumerableSet.AddressSet; + + struct IpRewardInfo { + uint256 startPoolBalance; // balance of pool when IP added to pool + uint256 rewardDebt; // pending reward = (PoolInfo.accBalance - startPoolBalance) / totalIp - ip.rewardDebt + } + + struct PoolInfo { + uint256 accBalance; + uint256 availableBalance; + } + + mapping(address groupId => mapping(address ipId => uint256 addedTime)) public ipAddedTime; + mapping(address groupId => uint256 totalMemberIPs) public totalMemberIPs; + mapping(address groupId => EnumerableSet.AddressSet tokens) internal groupTokens; + // Info of each token pool. groupId => { token => PoolInfo} + mapping(address groupId => mapping(address token => PoolInfo)) public poolInfo; + // Info of each user that stakes LP tokens. groupId => { token => { ipId => IpInfo}} + mapping(address groupId => mapping(address tokenId => mapping(address ipId => IpRewardInfo))) public ipRewardInfo; + + function addIp(address groupId, address ipId) external { + // ignore if IP is already added to pool + if (ipAddedTime[groupId][ipId] != 0) return; + ipAddedTime[groupId][ipId] = block.timestamp; + // set rewardDebt of IP to current availableReward of the IP + totalMemberIPs[groupId] += 1; + + EnumerableSet.AddressSet storage tokens = groupTokens[groupId]; + uint256 length = tokens.length(); + for (uint256 i = 0; i < length; i++) { + address token = tokens.at(i); + _collectRoyalties(groupId, token); + uint256 totalReward = poolInfo[groupId][token].accBalance; + ipRewardInfo[groupId][token][ipId].startPoolBalance = totalReward; + ipRewardInfo[groupId][token][ipId].rewardDebt = 0; + } + } + + function removeIp(address groupId, address ipId) external { + EnumerableSet.AddressSet storage tokens = groupTokens[groupId]; + uint256 length = tokens.length(); + address[] memory ipIds = new address[](1); + ipIds[0] = ipId; + for (uint256 i = 0; i < length; i++) { + address token = tokens.at(i); + _collectRoyalties(groupId, token); + _distributeRewards(groupId, token, ipIds); + ipAddedTime[groupId][ipId] = 0; + } + totalMemberIPs[groupId] -= 1; + } + + /// @notice Returns the reward for each IP in the group + /// @param groupId The group ID + /// @param token The reward token + /// @param ipIds The IP IDs + /// @return The rewards for each IP + function getAvailableReward( + address groupId, + address token, + address[] calldata ipIds + ) external view returns (uint256[] memory) { + return _getAvailableReward(groupId, token, ipIds); + } + + function distributeRewards( + address groupId, + address token, + address[] calldata ipIds + ) external returns (uint256[] memory rewards) { + return _distributeRewards(groupId, token, ipIds); + } + + function collectRoyalties(address groupId, address token) external { + _collectRoyalties(groupId, token); + } + + function _getAvailableReward( + address groupId, + address token, + address[] memory ipIds + ) internal view returns (uint256[] memory) { + uint256 totalAccumulatePoolBalance = poolInfo[groupId][token].accBalance; + uint256[] memory rewards = new uint256[](ipIds.length); + for (uint256 i = 0; i < ipIds.length; i++) { + // ignore if IP is not added to pool + if (ipAddedTime[groupId][ipIds[i]] == 0) { + rewards[i] = 0; + revert("IP not added to pool"); + continue; + } + uint256 poolBalanceBeforeIpAdded = ipRewardInfo[groupId][token][ipIds[i]].startPoolBalance; + uint256 rewardPerIP = (totalAccumulatePoolBalance - poolBalanceBeforeIpAdded) / totalMemberIPs[groupId]; + rewards[i] = rewardPerIP - ipRewardInfo[groupId][token][ipIds[i]].rewardDebt; + } + return rewards; + } + + function _distributeRewards( + address groupId, + address token, + address[] memory ipIds + ) internal returns (uint256[] memory rewards) { + rewards = _getAvailableReward(groupId, token, ipIds); + for (uint256 i = 0; i < ipIds.length; i++) { + // calculate pending reward for each IP + ipRewardInfo[groupId][token][ipIds[i]].rewardDebt += rewards[i]; + poolInfo[groupId][token].availableBalance -= rewards[i]; + // call royalty module to transfer reward to IP as royalty + IERC20(token).safeTransfer(ipIds[i], rewards[i]); + } + } + + function _collectRoyalties(address groupId, address token) internal { + // call royalty module to collect revenue of token + uint256 royalties = 0; + poolInfo[groupId][token].availableBalance += royalties; + poolInfo[groupId][token].accBalance += royalties; + groupTokens[groupId].add(token); + } + + function depositReward(address groupId, address token, uint256 amount) external { + IERC20(token).safeTransferFrom(msg.sender, address(this), amount); + poolInfo[groupId][token].accBalance += amount; + poolInfo[groupId][token].availableBalance += amount; + groupTokens[groupId].add(token); + } +} diff --git a/test/utils/BaseTest.t.sol b/test/utils/BaseTest.t.sol index 5bd1894..8ed3d5a 100644 --- a/test/utils/BaseTest.t.sol +++ b/test/utils/BaseTest.t.sol @@ -16,10 +16,15 @@ import { PILicenseTemplate } from "@storyprotocol/core/modules/licensing/PILicen import { LicensingModule } from "@storyprotocol/core/modules/licensing/LicensingModule.sol"; import { DisputeModule } from "@storyprotocol/core/modules/dispute/DisputeModule.sol"; import { RoyaltyModule } from "@storyprotocol/core/modules/royalty/RoyaltyModule.sol"; -import { RoyaltyPolicyLAP } from "@storyprotocol/core/modules/royalty/policies/RoyaltyPolicyLAP.sol"; +import { RoyaltyPolicyLAP } from "@storyprotocol/core/modules/royalty/policies/LAP/RoyaltyPolicyLAP.sol"; import { IpRoyaltyVault } from "@storyprotocol/core/modules/royalty/policies/IpRoyaltyVault.sol"; import { CoreMetadataModule } from "@storyprotocol/core/modules/metadata/CoreMetadataModule.sol"; import { CoreMetadataViewModule } from "@storyprotocol/core/modules/metadata/CoreMetadataViewModule.sol"; +import { LicenseToken } from "@storyprotocol/core/LicenseToken.sol"; +import { GroupNFT } from "@storyprotocol/core/GroupNFT.sol"; +import { GroupingModule } from "@storyprotocol/core/modules/grouping/GroupingModule.sol"; +import { MockEvenSplitGroupPool } from "../mocks/MockEvenSplitGroupPool.sol"; +import { IPGraphACL } from "@storyprotocol/core/access/IPGraphACL.sol"; import { StoryProtocolGateway } from "../../contracts/StoryProtocolGateway.sol"; import { SPGNFT } from "../../contracts/SPGNFT.sol"; @@ -50,6 +55,10 @@ contract BaseTest is Test { CoreMetadataViewModule internal coreMetadataViewModule; PILicenseTemplate internal pilTemplate; LicenseToken internal licenseToken; + GroupingModule internal groupingModule; + GroupNFT internal groupNFT; + IPGraphACL internal ipGraphACL; + MockEvenSplitGroupPool public rewardPool; StoryProtocolGateway internal spg; SPGNFT internal spgNftImpl; @@ -81,7 +90,7 @@ contract BaseTest is Test { } function setUp_test_Core() public { - address impl; + address impl = address(0); // Make sure we don't deploy wrong impl ERC6551Registry erc6551Registry = new ERC6551Registry(); @@ -111,7 +120,14 @@ contract BaseTest is Test { ); require(_loadProxyImpl(address(moduleRegistry)) == impl, "ModuleRegistry Proxy Implementation Mismatch"); - impl = address(new IPAssetRegistry(address(erc6551Registry), _getDeployedAddress(type(IPAccountImpl).name))); + impl = address(0); // Make sure we don't deploy wrong impl + impl = address( + new IPAssetRegistry( + address(erc6551Registry), + _getDeployedAddress(type(IPAccountImpl).name), + _getDeployedAddress(type(GroupingModule).name) + ) + ); ipAssetRegistry = IPAssetRegistry( TestProxyHelper.deployUUPSProxy( create3Deployer, @@ -126,6 +142,7 @@ contract BaseTest is Test { ); require(_loadProxyImpl(address(ipAssetRegistry)) == impl, "IPAssetRegistry Proxy Implementation Mismatch"); + impl = address(0); // Make sure we don't deploy wrong impl address ipAccountRegistry = address(ipAssetRegistry); impl = address(new AccessController(address(ipAssetRegistry), address(moduleRegistry))); @@ -143,10 +160,16 @@ contract BaseTest is Test { ); require(_loadProxyImpl(address(accessController)) == impl, "AccessController Proxy Implementation Mismatch"); + ipGraphACL = new IPGraphACL(address(protocolAccessManager)); + ipGraphACL.whitelistAddress(_getDeployedAddress(type(RoyaltyPolicyLAP).name)); + ipGraphACL.whitelistAddress(_getDeployedAddress(type(LicenseRegistry).name)); + + impl = address(0); // Make sure we don't deploy wrong impl impl = address( new LicenseRegistry( _getDeployedAddress(type(LicensingModule).name), - _getDeployedAddress(type(DisputeModule).name) + _getDeployedAddress(type(DisputeModule).name), + address(ipGraphACL) ) ); licenseRegistry = LicenseRegistry( @@ -180,6 +203,7 @@ contract BaseTest is Test { "Deploy: IP Account Impl Address Mismatch" ); + impl = address(0); // Make sure we don't deploy wrong impl impl = address( new DisputeModule(address(accessController), address(ipAssetRegistry), address(licenseRegistry)) ); @@ -197,11 +221,13 @@ contract BaseTest is Test { ); require(_loadProxyImpl(address(disputeModule)) == impl, "DisputeModule Proxy Implementation Mismatch"); + impl = address(0); // Make sure we don't deploy wrong impl impl = address( new RoyaltyModule( _getDeployedAddress(type(LicensingModule).name), address(disputeModule), - address(licenseRegistry) + address(licenseRegistry), + address(ipAssetRegistry) ) ); royaltyModule = RoyaltyModule( @@ -209,7 +235,7 @@ contract BaseTest is Test { create3Deployer, _getSalt(type(RoyaltyModule).name), impl, - abi.encodeCall(RoyaltyModule.initialize, address(protocolAccessManager)) + abi.encodeCall(RoyaltyModule.initialize, (address(protocolAccessManager), 1024, 1024, 10)) ) ); require( @@ -218,6 +244,7 @@ contract BaseTest is Test { ); require(_loadProxyImpl(address(royaltyModule)) == impl, "RoyaltyModule Proxy Implementation Mismatch"); + impl = address(0); // Make sure we don't deploy wrong impl impl = address( new LicensingModule( address(accessController), @@ -243,7 +270,8 @@ contract BaseTest is Test { ); require(_loadProxyImpl(address(licensingModule)) == impl, "LicensingModule Proxy Implementation Mismatch"); - impl = address(new RoyaltyPolicyLAP(address(royaltyModule), address(licensingModule))); + impl = address(0); // Make sure we don't deploy wrong impl + impl = address(new RoyaltyPolicyLAP(address(royaltyModule), address(licensingModule), address(ipGraphACL))); royaltyPolicyLAP = RoyaltyPolicyLAP( TestProxyHelper.deployUUPSProxy( create3Deployer, @@ -263,7 +291,7 @@ contract BaseTest is Test { _getSalt(type(IpRoyaltyVault).name), abi.encodePacked( type(IpRoyaltyVault).creationCode, - abi.encode(address(royaltyPolicyLAP), address(disputeModule)) + abi.encode(address(disputeModule), address(royaltyModule)) ) ) ); @@ -278,6 +306,7 @@ contract BaseTest is Test { ) ); + impl = address(0); // Make sure we don't deploy wrong impl impl = address(new LicenseToken(address(licensingModule), address(disputeModule))); licenseToken = LicenseToken( TestProxyHelper.deployUUPSProxy( @@ -299,6 +328,7 @@ contract BaseTest is Test { ); require(_loadProxyImpl(address(licenseToken)) == impl, "LicenseToken Proxy Implementation Mismatch"); + impl = address(0); // Make sure we don't deploy wrong impl impl = address( new PILicenseTemplate( address(accessController), @@ -348,17 +378,61 @@ contract BaseTest is Test { ) ); + impl = address(0); // Make sure we don't deploy wrong impl + impl = address(new GroupNFT(_getDeployedAddress(type(GroupingModule).name))); + groupNFT = GroupNFT( + TestProxyHelper.deployUUPSProxy( + create3Deployer, + _getSalt(type(GroupNFT).name), + impl, + abi.encodeCall( + GroupNFT.initialize, + ( + address(protocolAccessManager), + "https://github.com/storyprotocol/protocol-core/blob/main/assets/license-image.gif" + ) + ) + ) + ); + require(_getDeployedAddress(type(GroupNFT).name) == address(groupNFT), "Deploy: Group NFT Address Mismatch"); + require(_loadProxyImpl(address(groupNFT)) == impl, "GroupNFT Proxy Implementation Mismatch"); + + impl = address(0); // Make sure we don't deploy wrong impl + impl = address( + new GroupingModule( + address(accessController), + address(ipAssetRegistry), + address(licenseRegistry), + address(licenseToken), + address(groupNFT) + ) + ); + groupingModule = GroupingModule( + TestProxyHelper.deployUUPSProxy( + create3Deployer, + _getSalt(type(GroupingModule).name), + impl, + abi.encodeCall(GroupingModule.initialize, address(protocolAccessManager)) + ) + ); + require( + _getDeployedAddress(type(GroupingModule).name) == address(groupingModule), + "Deploy: Grouping Module Address Mismatch" + ); + require(_loadProxyImpl(address(groupingModule)) == impl, "GroupingModule Proxy Implementation Mismatch"); + moduleRegistry.registerModule("DISPUTE_MODULE", address(disputeModule)); moduleRegistry.registerModule("LICENSING_MODULE", address(licensingModule)); moduleRegistry.registerModule("ROYALTY_MODULE", address(royaltyModule)); moduleRegistry.registerModule("CORE_METADATA_MODULE", address(coreMetadataModule)); moduleRegistry.registerModule("CORE_METADATA_VIEW_MODULE", address(coreMetadataViewModule)); + moduleRegistry.registerModule("GROUPING_MODULE", address(groupingModule)); coreMetadataViewModule.updateCoreMetadataModule(); licenseRegistry.registerLicenseTemplate(address(pilTemplate)); royaltyModule.whitelistRoyaltyPolicy(address(royaltyPolicyLAP), true); - royaltyPolicyLAP.setIpRoyaltyVaultBeacon(address(ipRoyaltyVaultBeacon)); + royaltyModule.setIpRoyaltyVaultBeacon(address(ipRoyaltyVaultBeacon)); ipRoyaltyVaultBeacon.transferOwnership(address(royaltyPolicyLAP)); } @@ -372,7 +446,9 @@ contract BaseTest is Test { address(royaltyModule), address(coreMetadataModule), address(pilTemplate), - address(licenseToken) + address(licenseToken), + address(groupingModule), + address(groupNFT) ) ); spg = StoryProtocolGateway( @@ -409,6 +485,11 @@ contract BaseTest is Test { function setUp_test_Misc() public { mockToken = new MockERC20(); royaltyModule.whitelistRoyaltyToken(address(mockToken), true); + rewardPool = new MockEvenSplitGroupPool(); + + licenseRegistry.setDefaultLicenseTerms(address(pilTemplate), 0); + + groupingModule.whitelistGroupRewardPool(address(rewardPool)); vm.label(alice, "Alice"); vm.label(bob, "Bob");