diff --git a/contracts/modules/licensing/terms/RoyaltyTermsProcessor.sol b/contracts/modules/licensing/terms/RoyaltyTermsProcessor.sol new file mode 100644 index 00000000..2ba22d42 --- /dev/null +++ b/contracts/modules/licensing/terms/RoyaltyTermsProcessor.sol @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.19; + +import { BaseTermsProcessor } from "./BaseTermsProcessor.sol"; +import { IRoyaltyDistributor } from "contracts/modules/royalties/IRoyaltyDistributor.sol"; +import { IIPAccountRegistry } from "contracts/ip-accounts/IIPAccountRegistry.sol"; +import { IRoyaltyProportionPolicy } from "contracts/modules/royalties/policies/IRoyaltyProportionPolicy.sol"; +import { EmptyArray, LengthMismatch } from "contracts/errors/General.sol"; + +struct RoyaltyTermsConfig { + address payerNftContract; + uint256 payerTokenId; + address[] accounts; + uint32[] allocationPercentages; + bool isExecuted; +} + + +/// @title RoyaltyTermsProcessor +/// @notice Processor to set royalty split plan for Licensee. +/// the percentage of income from licensee will pay back to the licensor +contract RoyaltyTermsProcessor is BaseTermsProcessor { + IRoyaltyDistributor public immutable ROYALTY_DISTRIBUTOR; + + constructor(address authorizedExecutor, address royaltyDistributor) BaseTermsProcessor(authorizedExecutor) { + ROYALTY_DISTRIBUTOR = IRoyaltyDistributor(royaltyDistributor); + } + + /// @dev Parse license terms and translate license terms into Royalty split + function _executeTerms(bytes calldata data) internal virtual override returns (bytes memory newData) { + RoyaltyTermsConfig memory config = abi.decode(data, (RoyaltyTermsConfig)); + if (config.accounts.length == 0) { + revert EmptyArray(); + } + + if (config.accounts.length != config.allocationPercentages.length) { + revert LengthMismatch(); + } + IRoyaltyProportionPolicy.ProportionData memory policyData = IRoyaltyProportionPolicy.ProportionData({ + accounts: config.accounts, + percentAllocations: config.allocationPercentages + }); + ROYALTY_DISTRIBUTOR.updateDistribution(config.payerNftContract, config.payerTokenId, abi.encode(policyData)); + + RoyaltyTermsConfig memory newConfig = RoyaltyTermsConfig({ + payerNftContract: config.payerNftContract, + payerTokenId: config.payerTokenId, + accounts: config.accounts, + allocationPercentages: config.allocationPercentages, + isExecuted: true + }); + + newData = abi.encode(newConfig); + } + + /// @dev Return true if the terms exec executed with any errors. + function termsExecutedSuccessfully(bytes calldata data) external pure override returns (bool) { + RoyaltyTermsConfig memory config = abi.decode(data, (RoyaltyTermsConfig)); + return config.isExecuted; + } +} \ No newline at end of file diff --git a/test/foundry/integration/e2e.sol b/test/foundry/integration/e2e.sol new file mode 100644 index 00000000..dfea84f5 --- /dev/null +++ b/test/foundry/integration/e2e.sol @@ -0,0 +1,177 @@ +// SPDX-License-Identifier: BUSDL-1.1 +pragma solidity ^0.8.13; + +import "forge-std/Test.sol"; +import "contracts/ip-accounts/IIPAccount.sol"; +import "contracts/ip-accounts/IPAccountImpl.sol"; +import "contracts/ip-accounts/IPAccountRegistry.sol"; +import "contracts/modules/royalties/RoyaltyDistributor.sol"; +import "contracts/modules/royalties/RoyaltyNFT.sol"; +import "contracts/modules/royalties/ISplitMain.sol"; +import "contracts/modules/royalties/policies/MutableRoyaltyProportionPolicy.sol"; +import "contracts/modules/licensing/terms/RoyaltyTermsProcessor.sol"; +import '../utils/BaseTest.sol'; +import '../mocks/MockLicensingModule.sol'; +import '../mocks/MockTermsProcessor.sol'; +import '../mocks/RightsManagerHarness.sol'; +import "../mocks/MockERC721.sol"; +import "../mocks/MockERC20.sol"; +import "contracts/errors/General.sol"; + +contract E2ETest is BaseTest { + RoyaltyDistributor public royaltyDistributor; + IPAccountRegistry public registry; + IPAccountImpl public implementation; + RoyaltyNFT public royaltyNft; + ISplitMain public splitMain; + MutableRoyaltyProportionPolicy public mutablePolicy; + MockERC20 public mERC20; + RoyaltyTermsProcessor royaltyTermsProcessor; + + function setUp() virtual override public { + string memory mainnetRpc; + try vm.envString("MAINNET_RPC_URL") returns (string memory rpcUrl) { + mainnetRpc = rpcUrl; + } catch { + mainnetRpc = "https://eth-mainnet.g.alchemy.com/v2/demo"; + } + console.log(mainnetRpc); + uint256 mainnetFork = vm.createFork(mainnetRpc); + vm.selectFork(mainnetFork); + assertEq(vm.activeFork(), mainnetFork); + console.log(block.number); + // using the existing SplitMain + splitMain = ISplitMain(0x2ed6c4B5dA6378c7897AC67Ba9e43102Feb694EE); + royaltyNft = new RoyaltyNFT(address(splitMain)); + implementation = new IPAccountImpl(); + registry = new IPAccountRegistry(address(implementation)); + royaltyDistributor = new RoyaltyDistributor(address(registry), address(royaltyNft)); + mutablePolicy = new MutableRoyaltyProportionPolicy(address(royaltyNft)); + mERC20 = new MockERC20("Royalty Token", "RTK", 18); + deployProcessors = false; + super.setUp(); + royaltyTermsProcessor = new RoyaltyTermsProcessor(address(ipAssetRegistry), address(royaltyDistributor)); + } + + function test_e2e() public { + uint256 rootIpAssetId = ipAssetRegistry.createIPAsset( + IPAsset(1), + "rootIPAssetName", + "The root IP Asset description", + "http://root.ipasset.media.url", + alice, + 0 + ); + + uint256 ipAssetId = ipAssetRegistry.createIPAsset( + IPAsset(1), + "DerivativeIPAssetName", + "The derivative IP Asset description", + "http://derivative.ipasset.media.url", + bob, + rootIpAssetId + ); + + uint256 parentLicenseId = ipAssetRegistry.getLicenseIdByTokenId(rootIpAssetId, true); + address rootIpAccount = registry.createAccount(block.chainid, address(ipAssetRegistry), rootIpAssetId, ""); + address derivativeIpAccount = registry.createAccount(block.chainid, address(ipAssetRegistry), ipAssetId, ""); + // build Royalty Terms Config + address[] memory royaltyAccounts = new address[](2); + uint32[] memory royaltyPercentages = new uint32[](2); + royaltyAccounts[0] = rootIpAccount; + royaltyAccounts[1] = derivativeIpAccount; + royaltyPercentages[0] = 100000; + royaltyPercentages[1] = 900000; + RoyaltyTermsConfig memory termsConfig = RoyaltyTermsConfig({ + payerNftContract: address(ipAssetRegistry), + payerTokenId: ipAssetId, + accounts: royaltyAccounts, + allocationPercentages : royaltyPercentages, + isExecuted: false + }); + + // Create license + vm.prank(alice); + uint256 licenseId = ipAssetRegistry.createLicense( + ipAssetId, + parentLicenseId, + alice, + "https://derivative.commercial.license.uri", + revoker, + true, + true, + IERC5218.TermsProcessorConfig({ + processor: royaltyTermsProcessor, + data: abi.encode(termsConfig) + }) + ); + bool commercial = true; + (RightsManager.License memory license, address owner) = ipAssetRegistry.getLicense(licenseId); + assertEq(licenseId, 4); + assertEq(owner, alice); + assertEq(license.active, true); + assertEq(license.canSublicense, true); + assertEq(license.commercial, commercial); + assertEq(license.parentLicenseId, parentLicenseId); + assertEq(license.tokenId, ipAssetId); + assertEq(license.revoker, revoker); + assertEq(license.uri, "https://derivative.commercial.license.uri"); + assertEq(address(license.termsProcessor), address(royaltyTermsProcessor)); + assertEq(license.termsData, abi.encode(termsConfig)); + assertEq(licenseRegistry.ownerOf(licenseId), alice); + + royaltyDistributor.setRoyaltyPolicy(address(ipAssetRegistry), ipAssetId, address(mutablePolicy), ""); + + // execute license terms + vm.startPrank(alice); + ipAssetRegistry.executeTerms(licenseId); + licenseRegistry.transferFrom(alice, bob, licenseId); + vm.stopPrank(); + + assertEq(ipAssetRegistry.isLicenseActive(licenseId), true); + assertEq(royaltyNft.balanceOf(rootIpAccount, royaltyNft.toTokenId(derivativeIpAccount)), 100000); + assertEq(royaltyNft.balanceOf(derivativeIpAccount, royaltyNft.toTokenId(derivativeIpAccount)), 900000); + + mERC20.mint(10000); + mERC20.transfer(derivativeIpAccount, 10000); + assertEq(mERC20.balanceOf(derivativeIpAccount), 10000); + vm.startPrank(derivativeIpAccount); + mERC20.approve(address(royaltyNft), 10000); + vm.stopPrank(); + royaltyDistributor.distribute(address(ipAssetRegistry), ipAssetId, address(mERC20)); + // splitMain always reserve 1 as minimal balance + assertEq(splitMain.getERC20Balance(rootIpAccount, mERC20), 999); + assertEq(splitMain.getERC20Balance(derivativeIpAccount, mERC20), 8999); + assertEq(mERC20.balanceOf(rootIpAccount), 0); + assertEq(mERC20.balanceOf(derivativeIpAccount), 0); + + royaltyDistributor.claim(rootIpAccount, address(mERC20)); + assertEq(mERC20.balanceOf(rootIpAccount), 998); + royaltyDistributor.claim(derivativeIpAccount, address(mERC20)); + assertEq(mERC20.balanceOf(derivativeIpAccount),8998); + } + + function _getLicensingConfig() view internal virtual override returns (ILicensingModule.FranchiseConfig memory) { + return ILicensingModule.FranchiseConfig({ + nonCommercialConfig: ILicensingModule.IpAssetConfig({ + canSublicense: true, + franchiseRootLicenseId: 0 + }), + nonCommercialTerms: IERC5218.TermsProcessorConfig({ + processor: nonCommercialTermsProcessor, + data: abi.encode("nonCommercial") + }), + commercialConfig: ILicensingModule.IpAssetConfig({ + canSublicense: true, + franchiseRootLicenseId: 0 + }), + commercialTerms: IERC5218.TermsProcessorConfig({ + processor: commercialTermsProcessor, + data: abi.encode("commercial") + }), + rootIpAssetHasCommercialRights: true, + revoker: revoker, + commercialLicenseUri: "uriuri" + }); + } +} diff --git a/test/foundry/licensing/terms/RoyaltyTermsProcessor.t.sol b/test/foundry/licensing/terms/RoyaltyTermsProcessor.t.sol new file mode 100644 index 00000000..d6f71ab0 --- /dev/null +++ b/test/foundry/licensing/terms/RoyaltyTermsProcessor.t.sol @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: BUSDL-1.1 +pragma solidity ^0.8.13; + +import "forge-std/Test.sol"; +import 'test/foundry/utils/BaseTest.sol'; +import "contracts/errors/General.sol"; +import "contracts/modules/licensing/terms/RoyaltyTermsProcessor.sol"; +import "test/foundry/mocks/MockRoyaltyDistributor.sol"; + +contract RoyaltyTermsProcessorTest is BaseTest { + address licenseHolder = address(0x888888); + RoyaltyTermsProcessor processor; + uint256 licenseId; + uint256 ipAssetId; + uint256 parentLicenseId; + MockRoyaltyDistributor royaltyDistributor; + + address[] accounts; + uint32[] allocationPercentages; + + + function setUp() virtual override public { + deployProcessors = false; + super.setUp(); + royaltyDistributor = new MockRoyaltyDistributor(); + ipAssetId = ipAssetRegistry.createIPAsset(IPAsset(1), "name", "description", "mediaUrl", licenseHolder, 0); + parentLicenseId = ipAssetRegistry.getLicenseIdByTokenId(ipAssetId, false); + processor = new RoyaltyTermsProcessor(address(ipAssetRegistry), address(royaltyDistributor)); + } + + function test_revert_execute_terms_unauthorized() public { + bytes memory data = abi.encode(1); + vm.expectRevert(Unauthorized.selector); + processor.executeTerms(data); + } + + function test_revert_execute_terms_empty_accounts() public { + RoyaltyTermsConfig memory termsConfig = RoyaltyTermsConfig({ + payerNftContract: address(ipAssetRegistry), + payerTokenId: ipAssetId, + accounts: accounts, + allocationPercentages : allocationPercentages, + isExecuted: false + }); + vm.prank(address(ipAssetRegistry)); + vm.expectRevert(EmptyArray.selector); + processor.executeTerms(abi.encode(termsConfig)); + } + + function test_revert_execute_terms_mismatch_accounts_allocations() public { + accounts = [address(0x888888)]; + allocationPercentages = [100000, 900000]; + RoyaltyTermsConfig memory termsConfig = RoyaltyTermsConfig({ + payerNftContract: address(ipAssetRegistry), + payerTokenId: ipAssetId, + accounts: accounts, + allocationPercentages : allocationPercentages, + isExecuted: false + }); + vm.prank(address(ipAssetRegistry)); + vm.expectRevert(LengthMismatch.selector); + processor.executeTerms(abi.encode(termsConfig)); + } + + function test_execute_terms_start_on_license_creation() public { + accounts = [address(0x777777), address(0x888888)]; + allocationPercentages = [100000, 900000]; + RoyaltyTermsConfig memory config = RoyaltyTermsConfig({ + payerNftContract: address(ipAssetRegistry), + payerTokenId: ipAssetId, + accounts: accounts, + allocationPercentages : allocationPercentages, + isExecuted: false + }); + + IERC5218.TermsProcessorConfig memory termsConfig = IERC5218.TermsProcessorConfig({ + processor: processor, + data: abi.encode(config) + }); + + assertFalse(processor.termsExecutedSuccessfully(abi.encode(config)), "terms should be inactive before start time"); + + vm.prank(licenseHolder); + licenseId = ipAssetRegistry.createLicense( + ipAssetId, + parentLicenseId, + licenseHolder, + "licenseUri", + revoker, + false, + false, + termsConfig + ); + vm.prank(licenseHolder); + ipAssetRegistry.executeTerms(licenseId); + assertTrue(ipAssetRegistry.isLicenseActive(licenseId), "execution terms should make license active"); + (RightsManager.License memory license, address owner) = ipAssetRegistry.getLicense(licenseId); + assertEq(owner, licenseHolder); + assertTrue(processor.termsExecutedSuccessfully(license.termsData), "execution terms should executed successfully."); + } +} diff --git a/test/foundry/mocks/MockRoyaltyDistributor.sol b/test/foundry/mocks/MockRoyaltyDistributor.sol new file mode 100644 index 00000000..40c876a7 --- /dev/null +++ b/test/foundry/mocks/MockRoyaltyDistributor.sol @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.18; + +import "contracts/modules/royalties/IRoyaltyDistributor.sol"; +import { CollectModuleBase } from "contracts/modules/collect/CollectModuleBase.sol"; + +/// @title Mock Royalty Distributor +/// @notice This mock contract is used for testing the Royalty Terms Processor. +contract MockRoyaltyDistributor is IRoyaltyDistributor { + address public defaultRoyaltyPolicy; + /// @notice Set royalty policy to specified IP Asset. + /// @param royaltyPolicy The royalty distribution policy. + function setRoyaltyPolicy( + address, + uint256, + address royaltyPolicy, + bytes calldata + ) external { + defaultRoyaltyPolicy = royaltyPolicy; + } + + /// @notice Get royalty policy for specified IP Asset. + /// @return The address of royalty distribution policy. + function getRoyaltyPolicy(address, uint256) external view returns (address) { + return defaultRoyaltyPolicy; + } + + /// @notice update royalty distribution plan for given IP Asset. + /// @param nftContract address of NFT collection contract. + /// @param tokenId The NFT token Id of NFT collection contract. + /// @param data The royalty distribution plan data. + function updateDistribution(address nftContract, uint256 tokenId, bytes calldata data) external {} + + /// @notice distribute royalty to each recipient according to royalty distribution plan for given IP Asset. + /// @param nftContract address of NFT collection contract. + /// @param tokenId The NFT token Id of NFT collection contract. + /// @param token The ERC20 token for royalty. + function distribute(address nftContract, uint256 tokenId, address token) external {} + + /// @notice claim royalty to account. + /// @param account address of the account to which withdraw royalty which distributed before. + function claim(address account, address token) external {} + + /// @notice pause the royalty distribution. + function pause() external {} + + /// @notice unpause the royalty distribution. + function unpause() external {} +} diff --git a/test/foundry/utils/BaseTest.sol b/test/foundry/utils/BaseTest.sol index fed537f2..068d63fb 100644 --- a/test/foundry/utils/BaseTest.sol +++ b/test/foundry/utils/BaseTest.sol @@ -147,7 +147,7 @@ contract BaseTest is BaseTestUtils, ProxyHelper { } } - function _getLicensingConfig() view internal returns (ILicensingModule.FranchiseConfig memory) { + function _getLicensingConfig() view internal virtual returns (ILicensingModule.FranchiseConfig memory) { return ILicensingModule.FranchiseConfig({ nonCommercialConfig: ILicensingModule.IpAssetConfig({ canSublicense: true,