diff --git a/contracts/interfaces/story-nft/IStoryNFT.sol b/contracts/interfaces/story-nft/IStoryNFT.sol index e3c91dc..61d0c92 100644 --- a/contracts/interfaces/story-nft/IStoryNFT.sol +++ b/contracts/interfaces/story-nft/IStoryNFT.sol @@ -14,7 +14,6 @@ interface IStoryNFT is IERC721, IERC7572 { /// @notice Zero address provided as a param to StoryNFT constructor. error StoryNFT__ZeroAddressParam(); - //////////////////////////////////////////////////////////////////////////// // Structs // //////////////////////////////////////////////////////////////////////////// diff --git a/contracts/interfaces/workflows/IGroupingWorkflows.sol b/contracts/interfaces/workflows/IGroupingWorkflows.sol index e90140e..d25672b 100644 --- a/contracts/interfaces/workflows/IGroupingWorkflows.sol +++ b/contracts/interfaces/workflows/IGroupingWorkflows.sol @@ -76,4 +76,17 @@ interface IGroupingWorkflows { address licenseTemplate, uint256 licenseTermsId ) external returns (address groupId); + + /// @notice Collect royalties for the entire group and distribute the rewards to each member IP's royalty vault + /// @param groupId The ID of the group IP. + /// @param currencyTokens The addresses of the currency (revenue) tokens to claim. + /// @param groupSnapshotIds The IDs of the snapshots to collect royalties on. + /// @param memberIpIds The IDs of the member IPs to distribute the rewards to. + /// @return collectedRoyalties The amounts of royalties collected for each currency token. + function collectRoyaltiesAndClaimReward( + address groupId, + address[] calldata currencyTokens, + uint256[] calldata groupSnapshotIds, + address[] calldata memberIpIds + ) external returns (uint256[] memory collectedRoyalties); } diff --git a/contracts/workflows/GroupingWorkflows.sol b/contracts/workflows/GroupingWorkflows.sol index 51f04a1..18464a2 100644 --- a/contracts/workflows/GroupingWorkflows.sol +++ b/contracts/workflows/GroupingWorkflows.sol @@ -274,6 +274,26 @@ contract GroupingWorkflows is GROUP_NFT.safeTransferFrom(address(this), msg.sender, GROUP_NFT.totalSupply() - 1); } + /// @notice Collect royalties for the entire group and distribute the rewards to each member IP's royalty vault + /// @param groupId The ID of the group IP. + /// @param currencyTokens The addresses of the currency (revenue) tokens to claim. + /// @param groupSnapshotIds The IDs of the snapshots to collect royalties on. + /// @param memberIpIds The IDs of the member IPs to distribute the rewards to. + /// @return collectedRoyalties The amounts of royalties collected for each currency token. + function collectRoyaltiesAndClaimReward( + address groupId, + address[] calldata currencyTokens, + uint256[] calldata groupSnapshotIds, + address[] calldata memberIpIds + ) external returns (uint256[] memory collectedRoyalties) { + collectedRoyalties = new uint256[](currencyTokens.length); + for (uint256 i = 0; i < currencyTokens.length; i++) { + if (currencyTokens[i] == address(0)) revert Errors.GroupingWorkflows__ZeroAddressParam(); + collectedRoyalties[i] = GROUPING_MODULE.collectRoyalties(groupId, currencyTokens[i], groupSnapshotIds); + GROUPING_MODULE.claimReward(groupId, currencyTokens[i], memberIpIds); + } + } + // // Upgrade // diff --git a/script/deployment/MockRewardPool.s.sol b/script/deployment/MockRewardPool.s.sol deleted file mode 100644 index ae251e0..0000000 --- a/script/deployment/MockRewardPool.s.sol +++ /dev/null @@ -1,48 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity 0.8.26; -/* solhint-disable no-console */ - -// external -import { console2 } from "forge-std/console2.sol"; -import { Script } from "forge-std/Script.sol"; -import { stdJson } from "forge-std/StdJson.sol"; - -// script -import { BroadcastManager } from "../utils/BroadcastManager.s.sol"; -import { JsonDeploymentHandler } from "../utils/JsonDeploymentHandler.s.sol"; -import { StoryProtocolCoreAddressManager } from "../utils/StoryProtocolCoreAddressManager.sol"; - -// test -import { MockEvenSplitGroupPool } from "@storyprotocol/test/mocks/grouping/MockEvenSplitGroupPool.sol"; - -contract MockRewardPool is Script, BroadcastManager, JsonDeploymentHandler, StoryProtocolCoreAddressManager{ - using stdJson for string; - - constructor() JsonDeploymentHandler("main") {} - - /// @dev To use, run the following command (e.g., for Story Iliad testnet): - /// forge script script/deployment/MockRewardPool.s.sol:MockRewardPool --rpc-url=$TESTNET_URL \ - /// -vvvv --broadcast --priority-gas-price=1 --legacy \ - /// --verify --verifier=$VERIFIER_NAME --verifier-url=$VERIFIER_URL - /// - /// For detailed examples, see the documentation in `../../docs/DEPLOY_UPGRADE.md`. - function run() public { - _beginBroadcast(); // BroadcastManager.s.sol - _deployMockRewardPool(); - _endBroadcast(); // BroadcastManager.s.sol - } - - function _deployMockRewardPool() private { - _predeploy("MockEvenSplitGroupPool"); - MockEvenSplitGroupPool mockEvenSplitGroupPool = new MockEvenSplitGroupPool(royaltyModuleAddr); - _postdeploy("MockEvenSplitGroupPool", address(mockEvenSplitGroupPool)); - } - - function _predeploy(string memory contractKey) private pure { - console2.log(string.concat("Deploying ", contractKey, "...")); - } - - function _postdeploy(string memory contractKey, address newAddress) private pure{ - console2.log(string.concat(contractKey, " deployed to:"), newAddress); - } -} diff --git a/script/utils/DeployHelper.sol b/script/utils/DeployHelper.sol index 6258a3f..75453e5 100644 --- a/script/utils/DeployHelper.sol +++ b/script/utils/DeployHelper.sol @@ -15,6 +15,7 @@ import { CoreMetadataModule } from "@storyprotocol/core/modules/metadata/CoreMet import { CoreMetadataViewModule } from "@storyprotocol/core/modules/metadata/CoreMetadataViewModule.sol"; import { DisputeModule } from "@storyprotocol/core/modules/dispute/DisputeModule.sol"; import { GroupingModule } from "@storyprotocol/core/modules/grouping/GroupingModule.sol"; +import { EvenSplitGroupPool } from "@storyprotocol/core/modules/grouping/EvenSplitGroupPool.sol"; import { GroupNFT } from "@storyprotocol/core/GroupNFT.sol"; import { IPAccountImpl } from "@storyprotocol/core/IPAccountImpl.sol"; import { IPAssetRegistry } from "@storyprotocol/core/registries/IPAssetRegistry.sol"; @@ -32,7 +33,6 @@ import { RoyaltyPolicyLRP } from "@storyprotocol/core/modules/royalty/policies/L import { StorageLayoutChecker } from "@storyprotocol/script/utils/upgrades/StorageLayoutCheck.s.sol"; // contracts -import { IStoryNFT } from "../../contracts/interfaces/story-nft/IStoryNFT.sol"; import { SPGNFT } from "../../contracts/SPGNFT.sol"; import { DerivativeWorkflows } from "../../contracts/workflows/DerivativeWorkflows.sol"; import { GroupingWorkflows } from "../../contracts/workflows/GroupingWorkflows.sol"; @@ -114,6 +114,7 @@ contract DeployHelper is RoyaltyPolicyLAP internal royaltyPolicyLAP; RoyaltyPolicyLRP internal royaltyPolicyLRP; UpgradeableBeacon internal ipRoyaltyVaultBeacon; + EvenSplitGroupPool internal evenSplitGroupPool; // mock core contract deployer address internal mockDeployer; @@ -782,6 +783,28 @@ contract DeployHelper is "Deploy: Grouping Module Address Mismatch" ); require(_loadProxyImpl(address(groupingModule)) == impl, "GroupingModule Proxy Implementation Mismatch"); + + _predeploy("EvenSplitGroupPool"); + impl = address(new EvenSplitGroupPool( + address(groupingModule), + address(royaltyModule), + address(ipAssetRegistry) + )); + evenSplitGroupPool = EvenSplitGroupPool( + TestProxyHelper.deployUUPSProxy( + create3Deployer, + _getSalt(type(EvenSplitGroupPool).name), + impl, + abi.encodeCall(EvenSplitGroupPool.initialize, address(protocolAccessManager)) + ) + ); + require( + _getDeployedAddress(type(EvenSplitGroupPool).name) == address(evenSplitGroupPool), + "Deploy: EvenSplitGroupPool Address Mismatch" + ); + require(_loadProxyImpl(address(evenSplitGroupPool)) == impl, "EvenSplitGroupPool Proxy Implementation Mismatch"); + impl = address(0); + _postdeploy("EvenSplitGroupPool", address(evenSplitGroupPool)); } function _configureMockCoreContracts() private { @@ -807,6 +830,9 @@ contract DeployHelper is royaltyModule.whitelistRoyaltyPolicy(address(royaltyPolicyLRP), true); royaltyModule.setIpRoyaltyVaultBeacon(address(ipRoyaltyVaultBeacon)); ipRoyaltyVaultBeacon.transferOwnership(address(royaltyPolicyLAP)); + + // add evenSplitGroupPool to whitelist of group pools + groupingModule.whitelistGroupRewardPool(address(evenSplitGroupPool)); } /// @dev get the salt for the contract deployment with CREATE3 diff --git a/test/integration/workflows/RoyaltyIntegration.t.sol b/test/integration/workflows/RoyaltyIntegration.t.sol index 6f6ac2a..27d333a 100644 --- a/test/integration/workflows/RoyaltyIntegration.t.sol +++ b/test/integration/workflows/RoyaltyIntegration.t.sol @@ -121,7 +121,8 @@ contract RoyaltyIntegration is BaseIntegration { royaltyModule.maxPercent() + // 1000 * 10% = 100 royalty from childIpB (((defaultMintingFeeA * defaultCommRevShareA) / royaltyModule.maxPercent()) * defaultCommRevShareA) / royaltyModule.maxPercent() + // 1000 * 10% * 10% * 2 = 20 royalty from grandChildIp - defaultMintingFeeC + (defaultMintingFeeC * defaultCommRevShareC) / + defaultMintingFeeC + + (defaultMintingFeeC * defaultCommRevShareC) / royaltyModule.maxPercent() // 500 from from minting fee of childIpC,500 * 20% = 100 royalty from childIpC ); } @@ -189,7 +190,8 @@ contract RoyaltyIntegration is BaseIntegration { royaltyModule.maxPercent() + // 1000 * 10% = 100 royalty from childIpB (((defaultMintingFeeA * defaultCommRevShareA) / royaltyModule.maxPercent()) * defaultCommRevShareA) / royaltyModule.maxPercent() + // 1000 * 10% * 10% = 10 royalty from grandChildIp - defaultMintingFeeC + (defaultMintingFeeC * defaultCommRevShareC) / + defaultMintingFeeC + + (defaultMintingFeeC * defaultCommRevShareC) / royaltyModule.maxPercent() // 500 from from minting fee of childIpC, 500 * 20% = 100 royalty from childIpC ); } diff --git a/test/utils/BaseTest.t.sol b/test/utils/BaseTest.t.sol index afdf6fd..059765f 100644 --- a/test/utils/BaseTest.t.sol +++ b/test/utils/BaseTest.t.sol @@ -11,7 +11,6 @@ import { ICoreMetadataModule } from "@storyprotocol/core/interfaces/modules/meta import { IIPAccount } from "@storyprotocol/core/interfaces/IIPAccount.sol"; import { ILicensingModule } from "@storyprotocol/core/interfaces/modules/licensing/ILicensingModule.sol"; import { MetaTx } from "@storyprotocol/core/lib/MetaTx.sol"; -import { MockEvenSplitGroupPool } from "@storyprotocol/test/mocks/grouping/MockEvenSplitGroupPool.sol"; import { MockIPGraph } from "@storyprotocol/test/mocks/MockIPGraph.sol"; // contracts @@ -56,9 +55,6 @@ contract BaseTest is Test, DeployHelper { address internal CREATE3_DEPLOYER = address(new Create3Deployer()); uint256 internal CREATE3_DEFAULT_SEED = 1234567890; - /// @dev MockEvenSplitGroupPool for testing - MockEvenSplitGroupPool internal mockRewardPool; - /// @dev Mock assets MockERC20 internal mockToken; MockERC721 internal mockNft; @@ -122,11 +118,6 @@ contract BaseTest is Test, DeployHelper { groupingWorkflows.setNftContractBeacon(address(spgNftBeacon)); licenseAttachmentWorkflows.setNftContractBeacon(address(spgNftBeacon)); registrationWorkflows.setNftContractBeacon(address(spgNftBeacon)); - - // whitelist mockRewardPool as a group reward pool - mockRewardPool = new MockEvenSplitGroupPool(royaltyModuleAddr); - groupingModule.whitelistGroupRewardPool(address(mockRewardPool)); - vm.stopPrank(); } diff --git a/test/workflows/GroupingWorkflows.t.sol b/test/workflows/GroupingWorkflows.t.sol index e00dc5d..b87725c 100644 --- a/test/workflows/GroupingWorkflows.t.sol +++ b/test/workflows/GroupingWorkflows.t.sol @@ -7,10 +7,14 @@ import { Strings } from "@openzeppelin/contracts/utils/Strings.sol"; import { IIPAccount } from "@storyprotocol/core/interfaces/IIPAccount.sol"; import { ILicenseRegistry } from "@storyprotocol/core/interfaces/registries/ILicenseRegistry.sol"; import { IIPAssetRegistry } from "@storyprotocol/core/interfaces/registries/IIPAssetRegistry.sol"; +import { IIpRoyaltyVault } from "@storyprotocol/core/interfaces/modules/royalty/policies/IIpRoyaltyVault.sol"; import { IGroupingModule } from "@storyprotocol/core/interfaces/modules/grouping/IGroupingModule.sol"; import { IGroupIPAssetRegistry } from "@storyprotocol/core/interfaces/registries/IGroupIPAssetRegistry.sol"; +import { PILFlavors } from "@storyprotocol/core/lib/PILFlavors.sol"; +import { PILTerms } from "@storyprotocol/core/interfaces/modules/licensing/IPILicenseTemplate.sol"; // contracts +import { Errors } from "../../contracts/lib/Errors.sol"; import { LicensingHelper } from "../../contracts/lib/LicensingHelper.sol"; import { WorkflowStructs } from "../../contracts/lib/WorkflowStructs.sol"; @@ -22,7 +26,9 @@ import { BaseTest } from "../utils/BaseTest.t.sol"; contract GroupingWorkflowsTest is BaseTest { using Strings for uint256; - uint256 internal constant testLicenseTermsId = 1; + uint256 internal testLicenseTermsId; + PILTerms internal testLicenseTerms; + uint32 internal revShare; address internal groupOwner; address internal groupId; @@ -38,6 +44,16 @@ contract GroupingWorkflowsTest is BaseTest { groupOwner = u.bob; groupOwnerSk = sk.bob; + // register license terms + revShare = 10 * 10 ** 6; // 10% + testLicenseTerms = PILFlavors.commercialRemix({ + mintingFee: 0, + commercialRevShare: revShare, + currencyToken: address(mockToken), + royaltyPolicy: address(royaltyPolicyLAP) + }); + testLicenseTermsId = pilTemplate.registerLicenseTerms(testLicenseTerms); + // setup a group IPA _setupGroup(); @@ -177,7 +193,7 @@ contract GroupingWorkflowsTest is BaseTest { function test_GroupingWorkflows_registerGroupAndAttachLicense() public { vm.startPrank(groupOwner); address newGroupId = groupingWorkflows.registerGroupAndAttachLicense({ - groupPool: address(mockRewardPool), + groupPool: address(evenSplitGroupPool), licenseTemplate: address(pilTemplate), licenseTermsId: testLicenseTermsId }); @@ -199,7 +215,7 @@ contract GroupingWorkflowsTest is BaseTest { function test_GroupingWorkflows_registerGroupAndAttachLicenseAndAddIps() public { vm.startPrank(groupOwner); address newGroupId = groupingWorkflows.registerGroupAndAttachLicenseAndAddIps({ - groupPool: address(mockRewardPool), + groupPool: address(evenSplitGroupPool), ipIds: ipIds, licenseTemplate: address(pilTemplate), licenseTermsId: testLicenseTermsId @@ -224,6 +240,148 @@ contract GroupingWorkflowsTest is BaseTest { assertEq(licenseTermsId, testLicenseTermsId); } + // Collect royalties for the entire group and distribute to each member IP's royalty vault + function test_GroupingWorkflows_collectRoyaltiesAndClaimReward() public { + address ipOwner1 = u.bob; + address ipOwner2 = u.carl; + + vm.startPrank(groupOwner); + address newGroupId = groupingWorkflows.registerGroupAndAttachLicenseAndAddIps({ + groupPool: address(evenSplitGroupPool), + ipIds: ipIds, + licenseTemplate: address(pilTemplate), + licenseTermsId: testLicenseTermsId + }); + vm.stopPrank(); + + assertEq(ipAssetRegistry.totalMembers(newGroupId), 10); + assertEq(evenSplitGroupPool.getTotalIps(newGroupId), 10); + + address[] memory parentIpIds = new address[](1); + parentIpIds[0] = newGroupId; + uint256[] memory licenseTermsIds = new uint256[](1); + licenseTermsIds[0] = testLicenseTermsId; + + vm.startPrank(ipOwner1); + // approve nft minting fee + mockToken.mint(ipOwner1, 1 * 10 ** mockToken.decimals()); + mockToken.approve(address(spgNftPublic), 1 * 10 ** mockToken.decimals()); + + (address ipId1, ) = derivativeWorkflows.mintAndRegisterIpAndMakeDerivative({ + spgNftContract: address(spgNftPublic), + derivData: WorkflowStructs.MakeDerivative({ + parentIpIds: parentIpIds, + licenseTermsIds: licenseTermsIds, + licenseTemplate: address(pilTemplate), + royaltyContext: "" + }), + ipMetadata: ipMetadataDefault, + recipient: ipOwner1 + }); + vm.stopPrank(); + + vm.startPrank(ipOwner2); + // approve nft minting fee + mockToken.mint(ipOwner2, 1 * 10 ** mockToken.decimals()); + mockToken.approve(address(spgNftPublic), 1 * 10 ** mockToken.decimals()); + + (address ipId2, ) = derivativeWorkflows.mintAndRegisterIpAndMakeDerivative({ + spgNftContract: address(spgNftPublic), + derivData: WorkflowStructs.MakeDerivative({ + parentIpIds: parentIpIds, + licenseTermsIds: licenseTermsIds, + licenseTemplate: address(pilTemplate), + royaltyContext: "" + }), + ipMetadata: ipMetadataDefault, + recipient: ipOwner2 + }); + vm.stopPrank(); + + uint256 amount1 = 1_000 * 10 ** mockToken.decimals(); // 1,000 tokens + mockToken.mint(ipOwner1, amount1); + vm.startPrank(ipOwner1); + mockToken.approve(address(royaltyModule), amount1); + royaltyModule.payRoyaltyOnBehalf(ipId1, ipOwner1, address(mockToken), amount1); + royaltyPolicyLAP.transferToVault( + ipId1, + newGroupId, + address(mockToken), + (amount1 * revShare) / royaltyModule.maxPercent() + ); + vm.stopPrank(); + uint256 snapshotId1 = IIpRoyaltyVault(royaltyModule.ipRoyaltyVaults(newGroupId)).snapshot(); + + uint256 amount2 = 10_000 * 10 ** mockToken.decimals(); // 10,000 tokens + mockToken.mint(ipOwner2, amount2); + vm.startPrank(ipOwner2); + mockToken.approve(address(royaltyModule), amount2); + royaltyModule.payRoyaltyOnBehalf(ipId2, ipOwner2, address(mockToken), amount2); + royaltyPolicyLAP.transferToVault( + ipId2, + newGroupId, + address(mockToken), + (amount2 * revShare) / royaltyModule.maxPercent() + ); + vm.stopPrank(); + uint256 snapshotId2 = IIpRoyaltyVault(royaltyModule.ipRoyaltyVaults(newGroupId)).snapshot(); + + uint256[] memory snapshotIds = new uint256[](2); + address[] memory royaltyTokens = new address[](1); + snapshotIds[0] = snapshotId1; + snapshotIds[1] = snapshotId2; + royaltyTokens[0] = address(mockToken); + + { + /* TODO: This is a workaround to avoid the error where the member IP's IP royalty vault is not initialized + * when claiming reward. remove this when the issue is fixed in core protocol. + */ + for (uint256 i = 0; i < ipIds.length; i++) { + licensingModule.mintLicenseTokens({ + licensorIpId: ipIds[i], + licenseTemplate: address(pilTemplate), + licenseTermsId: testLicenseTermsId, + amount: 1, + receiver: u.admin, + royaltyContext: "" + }); + } + } + + uint256[] memory collectedRoyalties = groupingWorkflows.collectRoyaltiesAndClaimReward( + newGroupId, + royaltyTokens, + snapshotIds, + ipIds + ); + + assertEq(collectedRoyalties.length, 1); + assertEq( + collectedRoyalties[0], + (amount1 * revShare) / royaltyModule.maxPercent() + (amount2 * revShare) / royaltyModule.maxPercent() + ); + + // check each member IP received the reward in their IP royalty vault + for (uint256 i = 0; i < ipIds.length; i++) { + assertEq( + MockERC20(mockToken).balanceOf(royaltyModule.ipRoyaltyVaults(ipIds[i])), + collectedRoyalties[0] / ipIds.length // even split between all member IPs + ); + } + } + + // Revert if currency token contains zero address + function test_GroupingWorkflows_revert_collectRoyaltiesAndClaimReward_zeroAddressParam() public { + address[] memory currencyTokens = new address[](1); + currencyTokens[0] = address(0); + + uint256[] memory snapshotIds = new uint256[](1); + snapshotIds[0] = 0; + + vm.expectRevert(Errors.GroupingWorkflows__ZeroAddressParam.selector); + groupingWorkflows.collectRoyaltiesAndClaimReward(groupId, currencyTokens, snapshotIds, ipIds); + } + // Multicall (mint → Register IP → Attach PIL terms → Add new IP to group IPA) function test_GroupingWorkflows_multicall_mintAndRegisterIpAndAttachLicenseAndAddToGroup() public { uint256 deadline = block.timestamp + 1000; @@ -371,7 +529,7 @@ contract GroupingWorkflowsTest is BaseTest { function _setupGroup() internal { // register a group and attach default PIL terms to it vm.startPrank(groupOwner); - groupId = IGroupingModule(groupingModule).registerGroup(address(mockRewardPool)); + groupId = IGroupingModule(groupingModule).registerGroup(address(evenSplitGroupPool)); vm.label(groupId, "Group1"); LicensingHelper.attachLicenseTerms( groupId,