diff --git a/contracts/interfaces/modules/royalty/IRoyaltyModule.sol b/contracts/interfaces/modules/royalty/IRoyaltyModule.sol index a75f8bb23..7e34caee0 100644 --- a/contracts/interfaces/modules/royalty/IRoyaltyModule.sol +++ b/contracts/interfaces/modules/royalty/IRoyaltyModule.sol @@ -3,23 +3,51 @@ pragma solidity ^0.8.23; /// @title RoyaltyModule interface interface IRoyaltyModule { + /// @notice Event emitted when a royalty policy is whitelisted + /// @param royaltyPolicy The address of the royalty policy + /// @param allowed Indicates if the royalty policy is whitelisted or not + event RoyaltyPolicyWhitelistUpdated(address royaltyPolicy, bool allowed); + + /// @notice Event emitted when a royalty token is whitelisted + /// @param token The address of the royalty token + /// @param allowed Indicates if the royalty token is whitelisted or not + event RoyaltyTokenWhitelistUpdated(address token, bool allowed); + + /// @notice Event emitted when a royalty policy is set + /// @param ipId The ipId + /// @param royaltyPolicy The address of the royalty policy + /// @param data The data to initialize the policy + event RoyaltyPolicySet(address ipId, address royaltyPolicy, bytes data); + + /// @notice Event emitted when royalties are paid + /// @param receiverIpId The ipId that receives the royalties + /// @param payerIpId The ipId that pays the royalties + /// @param sender The address that pays the royalties on behalf of the payer ipId + /// @param token The token that is used to pay the royalties + /// @param amount The amount that is paid + event RoyaltyPaid(address receiverIpId, address payerIpId, address sender, address token, uint256 amount); + /// @notice Whitelist a royalty policy /// @param royaltyPolicy The address of the royalty policy /// @param allowed Indicates if the royalty policy is whitelisted or not function whitelistRoyaltyPolicy(address royaltyPolicy, bool allowed) external; + /// @notice Whitelist a royalty token + /// @param token The token address + /// @param allowed Indicates if the token is whitelisted or not + function whitelistRoyaltyToken(address token, bool allowed) external; + /// @notice Sets the royalty policy for an ipId /// @param ipId The ipId /// @param royaltyPolicy The address of the royalty policy + /// @param parentIpIds The parent ipIds /// @param data The data to initialize the policy - function setRoyaltyPolicy(address ipId, address royaltyPolicy, bytes calldata data) external; + function setRoyaltyPolicy(address ipId, address royaltyPolicy, address[] calldata parentIpIds, bytes calldata data) external; - /// @notice Allows an IPAccount to pay royalties - /// @param ipId The ipId - /// @param token The token to pay the royalties in + /// @notice Allows a sender to to pay royalties on behalf of an ipId + /// @param receiverIpId The ipId that receives the royalties + /// @param payerIpId The ipId that pays the royalties + /// @param token The token to use to pay the royalties /// @param amount The amount to pay - function payRoyalty(address ipId, address token, uint256 amount) external; - - /// @notice Gets the royalty policy for a given ipId - function royaltyPolicies(address ipId) external view returns (address royaltyPolicy); + function payRoyaltyOnBehalf(address receiverIpId, address payerIpId, address token, uint256 amount) external; } \ No newline at end of file diff --git a/contracts/interfaces/modules/royalty/policies/IClaimerLS.sol b/contracts/interfaces/modules/royalty/policies/IClaimerLS.sol new file mode 100644 index 000000000..f85d9bc78 --- /dev/null +++ b/contracts/interfaces/modules/royalty/policies/IClaimerLS.sol @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.23; + +import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +/// @title Liquid split policy claimer interface +interface IClaimerLS { + /// @notice Event emitted when a claim is made + /// @param path The path from the ipId to the claimer + /// @param claimer The claimer ipId address + /// @param withdrawETH Indicates if the claimer wants to withdraw ETH + /// @param tokens The ERC20 tokens to withdraw + event Claimed(address[] path, address claimer, bool withdrawETH, ERC20[] tokens); + + /// @notice Allows an ipId to claim their rnfts and accrued royalties + /// @param path The path of the ipId + /// @param claimerIpId The ipId of the claimer + /// @param withdrawETH Indicates if the claimer wants to withdraw ETH + /// @param tokens The ERC20 tokens to withdraw + function claim(address[] calldata path, address claimerIpId, bool withdrawETH, ERC20[] calldata tokens) external; +} \ No newline at end of file diff --git a/contracts/interfaces/modules/royalty/policies/ILiquidSplitClone.sol b/contracts/interfaces/modules/royalty/policies/ILiquidSplitClone.sol index f8ece07cb..61cff364f 100644 --- a/contracts/interfaces/modules/royalty/policies/ILiquidSplitClone.sol +++ b/contracts/interfaces/modules/royalty/policies/ILiquidSplitClone.sol @@ -8,4 +8,20 @@ interface ILiquidSplitClone { /// @param accounts The accounts to distribute to /// @param distributorAddress The distributor address function distributeFunds(address token, address[] calldata accounts, address distributorAddress) external; + + /// @notice Transfers rnft tokens + /// @param from The address to transfer from + /// @param to The address to transfer to + /// @param id The token id + /// @param amount The amount to transfer + /// @param data Custom data + function safeTransferFrom( + address from, + address to, + uint256 id, + uint256 amount, + bytes calldata data + ) external; + + function balanceOf(address account, uint256 id) external view returns (uint256); } \ No newline at end of file diff --git a/contracts/interfaces/modules/royalty/policies/ILiquidSplitMain.sol b/contracts/interfaces/modules/royalty/policies/ILiquidSplitMain.sol index ba1380935..5e89b83c6 100644 --- a/contracts/interfaces/modules/royalty/policies/ILiquidSplitMain.sol +++ b/contracts/interfaces/modules/royalty/policies/ILiquidSplitMain.sol @@ -14,4 +14,13 @@ interface ILiquidSplitMain { uint256 withdrawETH, ERC20[] calldata tokens ) external; + + /// @notice Gets the ETH balance of an account + /// @param account The account to get the ETH balance of + function getETHBalance(address account) external view returns (uint256); + + /// @notice Gets the ERC20 balance of an account + /// @param account The account to get the ERC20 balance of + /// @param token The token to get the balance of + function getERC20Balance(address account, ERC20 token) external view returns (uint256); } \ No newline at end of file diff --git a/contracts/interfaces/modules/royalty/policies/IRoyaltyPolicy.sol b/contracts/interfaces/modules/royalty/policies/IRoyaltyPolicy.sol index ad97f6b61..5d4142002 100644 --- a/contracts/interfaces/modules/royalty/policies/IRoyaltyPolicy.sol +++ b/contracts/interfaces/modules/royalty/policies/IRoyaltyPolicy.sol @@ -5,8 +5,9 @@ pragma solidity ^0.8.23; interface IRoyaltyPolicy { /// @notice Initializes the royalty policy /// @param ipId The ipId - /// @param data The data to initialize the policy - function initPolicy(address ipId, bytes calldata data) external; + /// @param parentsIpIds The parent ipIds + /// @param data The data to initialize the policy + function initPolicy(address ipId, address[] calldata parentsIpIds, bytes calldata data) external; /// @notice Allows to pay a royalty /// @param caller The caller diff --git a/contracts/interfaces/modules/royalty/policies/IRoyaltyPolicyLS.sol b/contracts/interfaces/modules/royalty/policies/IRoyaltyPolicyLS.sol new file mode 100644 index 000000000..5f856f82a --- /dev/null +++ b/contracts/interfaces/modules/royalty/policies/IRoyaltyPolicyLS.sol @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.23; + +import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +import { IRoyaltyPolicy } from "contracts/interfaces/modules/royalty/policies/IRoyaltyPolicy.sol"; + +/// @title RoyaltyPolicy interface +interface IRoyaltyPolicyLS is IRoyaltyPolicy { + /// @notice Gets the royalty data + /// @param ipId The ipId + /// @return splitClone The split clone address + /// claimer The claimer address + /// royaltyStack The royalty stack + /// minRoyalty The min royalty + function royaltyData(address ipId) external view returns (address splitClone, address claimer, uint32 royaltyStack, uint32 minRoyalty); + + /// @notice Distributes funds to the accounts in the LiquidSplitClone contract + /// @param ipId The ipId + /// @param token The token to distribute + /// @param accounts The accounts to distribute to + /// @param distributorAddress The distributor address + function distributeFunds( + address ipId, + address token, + address[] calldata accounts, + address distributorAddress + ) external; + + /// @notice Claims the available royalties for a given account + /// @param account The account to claim for + /// @param withdrawETH The amount of ETH to withdraw + /// @param tokens The tokens to withdraw + function claimRoyalties(address account, uint256 withdrawETH, ERC20[] calldata tokens) external; + + /// @notice Gets liquid split main address + function LIQUID_SPLIT_MAIN() external view returns (address); +} \ No newline at end of file diff --git a/contracts/lib/Errors.sol b/contracts/lib/Errors.sol index f4c38d3fb..6b24d6c60 100644 --- a/contracts/lib/Errors.sol +++ b/contracts/lib/Errors.sol @@ -145,12 +145,33 @@ library Errors { error RoyaltyModule__ZeroRoyaltyPolicy(); error RoyaltyModule__NotWhitelistedRoyaltyPolicy(); error RoyaltyModule__AlreadySetRoyaltyPolicy(); + error RoyaltyModule__ZeroRoyaltyToken(); + error RoyaltyModule__NotWhitelistedRoyaltyToken(); + error RoyaltyModule__NoRoyaltyPolicySet(); + error RoyaltyModule__IncompatibleRoyaltyPolicy(); error RoyaltyPolicyLS__ZeroRoyaltyModule(); error RoyaltyPolicyLS__ZeroLiquidSplitFactory(); error RoyaltyPolicyLS__ZeroLiquidSplitMain(); error RoyaltyPolicyLS__NotRoyaltyModule(); error RoyaltyPolicyLS__TransferFailed(); + error RoyaltyPolicyLS__InvalidMinRoyalty(); + error RoyaltyPolicyLS__InvalidRoyaltyStack(); + error RoyaltyPolicyLS__ZeroMinRoyalty(); + error RoyaltyPolicyLS__ZeroLicenseRegistry(); + + error LSClaimer__InvalidPath(); + error LSClaimer__InvalidPathFirstPosition(); + error LSClaimer__InvalidPathLastPosition(); + error LSClaimer__AlreadyClaimed(); + error LSClaimer__ZeroRNFT(); + error LSClaimer__RNFTAlreadySet(); + error LSClaimer__ETHBalanceNotZero(); + error LSClaimer__ERC20BalanceNotZero(); + error LSClaimer__ZeroIpId(); + error LSClaimer__ZeroLicenseRegistry(); + error LSClaimer__ZeroRoyaltyPolicyLS(); + error LSClaimer__NotRoyaltyPolicyLS(); //////////////////////////////////////////////////////////////////////////// // ModuleRegistry // diff --git a/contracts/modules/royalty-module/RoyaltyModule.sol b/contracts/modules/royalty-module/RoyaltyModule.sol index 63b53ddc9..a151aee52 100644 --- a/contracts/modules/royalty-module/RoyaltyModule.sol +++ b/contracts/modules/royalty-module/RoyaltyModule.sol @@ -15,6 +15,9 @@ contract RoyaltyModule is IRoyaltyModule, ReentrancyGuard { /// @notice Indicates if a royalty policy is whitelisted mapping(address royaltyPolicy => bool allowed) public isWhitelistedRoyaltyPolicy; + /// @notice Indicates if a royalty token is whitelisted + mapping(address token => bool) public isWhitelistedRoyaltyToken; + /// @notice Indicates the royalty policy for a given ipId mapping(address ipId => address royaltyPolicy) public royaltyPolicies; @@ -25,17 +28,11 @@ contract RoyaltyModule is IRoyaltyModule, ReentrancyGuard { } /// @notice Restricts the calls to the license module - modifier onlyLicenseModule() { + modifier onlyLicensingModule() { // TODO: where is license module address defined? _; } - /// @notice Restricts the calls to a IPAccount - modifier onlyIPAccount() { - // TODO: where to find if an address is a valid IPAccount or an approved operator? - _; - } - /// @notice Whitelist a royalty policy /// @param _royaltyPolicy The address of the royalty policy /// @param _allowed Indicates if the royalty policy is whitelisted or not @@ -44,37 +41,63 @@ contract RoyaltyModule is IRoyaltyModule, ReentrancyGuard { isWhitelistedRoyaltyPolicy[_royaltyPolicy] = _allowed; - // TODO: emit event + emit RoyaltyPolicyWhitelistUpdated(_royaltyPolicy, _allowed); } + /// @notice Whitelist a royalty token + /// @param _token The token address + /// @param _allowed Indicates if the token is whitelisted or not + function whitelistRoyaltyToken(address _token, bool _allowed) external onlyGovernance { + if (_token == address(0)) revert Errors.RoyaltyModule__ZeroRoyaltyToken(); + + isWhitelistedRoyaltyToken[_token] = _allowed; + + emit RoyaltyTokenWhitelistUpdated(_token, _allowed); + } + + // TODO: Ensure this function is called on ipId registration: root and derivatives registrations + // TODO: Ensure that the ipId that is passed in from license cannot be manipulated - given ipId addresses are deterministic /// @notice Sets the royalty policy for an ipId /// @param _ipId The ipId /// @param _royaltyPolicy The address of the royalty policy + /// @param _parentIpIds The parent ipIds /// @param _data The data to initialize the policy function setRoyaltyPolicy( - address _ipId, + address _ipId, address _royaltyPolicy, + address[] calldata _parentIpIds, bytes calldata _data - ) external onlyLicenseModule nonReentrant { - // TODO: make call to ensure ipId exists/has been registered - if (!isWhitelistedRoyaltyPolicy[_royaltyPolicy]) revert Errors.RoyaltyModule__NotWhitelistedRoyaltyPolicy(); + ) external onlyLicensingModule nonReentrant { if (royaltyPolicies[_ipId] != address(0)) revert Errors.RoyaltyModule__AlreadySetRoyaltyPolicy(); - // TODO: check if royalty policy is compatible with parents royalty policy + if (!isWhitelistedRoyaltyPolicy[_royaltyPolicy]) revert Errors.RoyaltyModule__NotWhitelistedRoyaltyPolicy(); + + for (uint32 i = 0; i < _parentIpIds.length; i++) { + if (royaltyPolicies[_parentIpIds[i]] != _royaltyPolicy) revert Errors.RoyaltyModule__IncompatibleRoyaltyPolicy(); + } royaltyPolicies[_ipId] = _royaltyPolicy; - IRoyaltyPolicy(_royaltyPolicy).initPolicy(_ipId, _data); + IRoyaltyPolicy(_royaltyPolicy).initPolicy(_ipId, _parentIpIds, _data); - // TODO: emit event + emit RoyaltyPolicySet(_ipId, _royaltyPolicy, _data); } - /// @notice Allows an IPAccount to pay royalties - /// @param _ipId The ipId - /// @param _token The token to pay the royalties in + /// @notice Allows a sender to to pay royalties on behalf of an ipId + /// @param _receiverIpId The ipId that receives the royalties + /// @param _payerIpId The ipId that pays the royalties + /// @param _token The token to use to pay the royalties /// @param _amount The amount to pay - function payRoyalty(address _ipId, address _token, uint256 _amount) external onlyIPAccount nonReentrant { - IRoyaltyPolicy(royaltyPolicies[_ipId]).onRoyaltyPayment(msg.sender, _ipId, _token, _amount); + function payRoyaltyOnBehalf(address _receiverIpId, address _payerIpId, address _token, uint256 _amount) external nonReentrant { + address royaltyPolicy = royaltyPolicies[_receiverIpId]; + if (royaltyPolicy == address(0)) revert Errors.RoyaltyModule__NoRoyaltyPolicySet(); + if (!isWhitelistedRoyaltyToken[_token]) revert Errors.RoyaltyModule__NotWhitelistedRoyaltyToken(); + + // TODO: check how to handle the below with replacement + if (!isWhitelistedRoyaltyPolicy[royaltyPolicy]) revert Errors.RoyaltyModule__NotWhitelistedRoyaltyPolicy(); + + IRoyaltyPolicy(royaltyPolicy).onRoyaltyPayment(msg.sender, _receiverIpId, _token, _amount); - // TODO: emit event + // TODO: review event vs variable for royalty tracking + emit RoyaltyPaid(_receiverIpId, _payerIpId, msg.sender, _token, _amount); } -} +} \ No newline at end of file diff --git a/contracts/modules/royalty-module/policies/LSClaimer.sol b/contracts/modules/royalty-module/policies/LSClaimer.sol new file mode 100644 index 000000000..e59f6f87d --- /dev/null +++ b/contracts/modules/royalty-module/policies/LSClaimer.sol @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.23; + +// external +import { ReentrancyGuard } from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; +import { ERC1155Holder } from "@openzeppelin/contracts/token/ERC1155/utils/ERC1155Holder.sol"; +import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +// contracts +import { ILicenseRegistry } from "contracts/interfaces/registries/ILicenseRegistry.sol"; +import { ILiquidSplitClone } from "contracts/interfaces/modules/royalty/policies/ILiquidSplitClone.sol"; +import { IRoyaltyPolicyLS } from "contracts/interfaces/modules/royalty/policies/IRoyaltyPolicyLS.sol"; +import { ILiquidSplitMain } from "contracts/interfaces/modules/royalty/policies/ILiquidSplitMain.sol"; +import { IClaimerLS } from "contracts/interfaces/modules/royalty/policies/IClaimerLS.sol"; +import { Errors } from "contracts/lib/Errors.sol"; + +/// @title Liquid Split Claimer +/// @notice The liquid split claimer allows parents and grandparents to claim their share +/// the rnfts of their children and grandchildren along with any accrued royalties. +contract LSClaimer is IClaimerLS, ERC1155Holder, ReentrancyGuard { + using SafeERC20 for IERC20; + + /// @notice The license registry interface + ILicenseRegistry public immutable ILICENSE_REGISTRY; + + /// @notice The liquid split royalty policy interface + IRoyaltyPolicyLS public immutable IROYALTY_POLICY_LS; + + /// @notice The ipId of the IP that this contract is associated with + address public immutable IP_ID; + + /// @notice The paths between parent and children that have already been claimed + mapping(bytes32 pathHash => bool) public claimedPaths; + + /// @notice Constructor + /// @param _ipId The ipId of the IP that this contract is associated with + /// @param _licenseRegistry The license registry address + /// @param _royaltyPolicyLS The liquid split royalty policy address + constructor(address _ipId, address _licenseRegistry, address _royaltyPolicyLS) { + if (_ipId == address(0)) revert Errors.LSClaimer__ZeroIpId(); + if (_licenseRegistry == address(0)) revert Errors.LSClaimer__ZeroLicenseRegistry(); + if (_royaltyPolicyLS == address(0)) revert Errors.LSClaimer__ZeroRoyaltyPolicyLS(); + + IP_ID = _ipId; + ILICENSE_REGISTRY = ILicenseRegistry(_licenseRegistry); + IROYALTY_POLICY_LS = IRoyaltyPolicyLS(_royaltyPolicyLS); + } + + /// @notice Allows an parent or grandparent ipId to claim their rnfts and accrued royalties + /// @param _path The path between the IP_ID and the parent or grandparent ipId + /// @param _claimerIpId The ipId of the claimer + /// @param _withdrawETH Indicates if the claimer wants to withdraw ETH + /// @param _tokens The ERC20 tokens to withdraw + function claim(address[] calldata _path, address _claimerIpId, bool _withdrawETH, ERC20[] calldata _tokens) external nonReentrant { + bytes32 pathHash = keccak256(abi.encodePacked(_path)); + if (claimedPaths[pathHash]) revert Errors.LSClaimer__AlreadyClaimed(); + + // check if path is valid + if (_path[0] != _claimerIpId) revert Errors.LSClaimer__InvalidPathFirstPosition(); + if (_path[_path.length - 1] != IP_ID) revert Errors.LSClaimer__InvalidPathLastPosition(); + _checkIfPathIsValid(_path); + + // claim rnfts + (address rnftAddr,,,) = IROYALTY_POLICY_LS.royaltyData(IP_ID); + ILiquidSplitClone rnft = ILiquidSplitClone(rnftAddr); + uint256 totalUnclaimedRnfts = rnft.balanceOf(address(this), 0); + (,,,uint32 rnftClaimAmount) = IROYALTY_POLICY_LS.royaltyData(_claimerIpId); + rnft.safeTransferFrom(address(this), _claimerIpId, 0, rnftClaimAmount, ""); + + // claim accrued tokens (if any) + _claimAccruedTokens(rnftClaimAmount, totalUnclaimedRnfts, _claimerIpId, _withdrawETH, _tokens); + + claimedPaths[pathHash] = true; + + emit Claimed(_path, _claimerIpId, _withdrawETH, _tokens); + } + + /// @notice Checks if a claiming path is valid + /// @param _path The path between the IP_ID and the parent or grandparent ipId + function _checkIfPathIsValid(address[] calldata _path) internal view { + // the loop below is limited to no more than 100 parents + // given the minimum royalty step of 1% and there is a cap of 100% + for (uint256 i = 0; i < _path.length - 1; i++) { + if(!ILICENSE_REGISTRY.isParent(_path[i], _path[i+1])) revert Errors.LSClaimer__InvalidPath(); + } + } + + /// @notice Claims the accrued tokens (if any) + /// @param _rnftClaimAmount The amount of rnfts to claim + /// @param _totalUnclaimedRnfts The total unclaimed rnfts + /// @param _claimerIpId The ipId of the claimer + /// @param _withdrawETH Indicates if the claimer wants to withdraw ETH + /// @param _tokens The ERC20 tokens to withdraw + function _claimAccruedTokens(uint256 _rnftClaimAmount, uint256 _totalUnclaimedRnfts, address _claimerIpId, bool _withdrawETH, ERC20[] calldata _tokens) internal { + ILiquidSplitMain splitMain = ILiquidSplitMain(IROYALTY_POLICY_LS.LIQUID_SPLIT_MAIN()); + + if (_withdrawETH) { + if (splitMain.getETHBalance(address(this)) != 0) revert Errors.LSClaimer__ETHBalanceNotZero(); + + uint256 ethBalance = address(this).balance; + uint256 ethClaimAmount = ethBalance * _rnftClaimAmount / _totalUnclaimedRnfts; + + _safeTransferETH(_claimerIpId, ethClaimAmount); + } + + for (uint256 i = 0; i < _tokens.length; ++i) { + if (splitMain.getERC20Balance(address(this), _tokens[i]) != 0) revert Errors.LSClaimer__ERC20BalanceNotZero(); + + IERC20 IToken = IERC20(_tokens[i]); + uint256 tokenBalance = IToken.balanceOf(address(this)); + uint256 tokenClaimAmount = tokenBalance * _rnftClaimAmount / _totalUnclaimedRnfts; + + IToken.safeTransfer(_claimerIpId, tokenClaimAmount); + } + } + + /// @notice Allows to transfers ETH + /// @param _to The address to transfer to + /// @param _amount The amount to transfer + function _safeTransferETH(address _to, uint256 _amount) internal { + bool callStatus; + + assembly { + // Transfer the ETH and store if it succeeded or not. + callStatus := call(gas(), _to, _amount, 0, 0, 0, 0) + } + + if (!callStatus) revert Errors.RoyaltyPolicyLS__TransferFailed(); + } +} \ No newline at end of file diff --git a/contracts/modules/royalty-module/policies/RoyaltyPolicyLS.sol b/contracts/modules/royalty-module/policies/RoyaltyPolicyLS.sol index 3a8145ca7..4dccd3942 100644 --- a/contracts/modules/royalty-module/policies/RoyaltyPolicyLS.sol +++ b/contracts/modules/royalty-module/policies/RoyaltyPolicyLS.sol @@ -5,30 +5,45 @@ pragma solidity ^0.8.23; import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import { ERC1155Holder } from "@openzeppelin/contracts/token/ERC1155/utils/ERC1155Holder.sol"; // contracts +import { LSClaimer } from "contracts/modules/royalty-module/policies/LSClaimer.sol"; import { ILiquidSplitClone } from "contracts/interfaces/modules/royalty/policies/ILiquidSplitClone.sol"; import { ILiquidSplitFactory } from "contracts/interfaces/modules/royalty/policies/ILiquidSplitFactory.sol"; import { ILiquidSplitMain } from "contracts/interfaces/modules/royalty/policies/ILiquidSplitMain.sol"; -import { IRoyaltyPolicy } from "contracts/interfaces/modules/royalty/policies/IRoyaltyPolicy.sol"; +import { IRoyaltyPolicyLS } from "contracts/interfaces/modules/royalty/policies/IRoyaltyPolicyLS.sol"; import { Errors } from "contracts/lib/Errors.sol"; /// @title Liquid Split Royalty Policy /// @notice The LiquidSplit royalty policy splits royalties in accordance with /// the percentage of royalty NFTs owned by each account. -contract RoyaltyPolicyLS is IRoyaltyPolicy { +contract RoyaltyPolicyLS is IRoyaltyPolicyLS, ERC1155Holder { using SafeERC20 for IERC20; + struct LSRoyaltyData { + address splitClone; // address of the liquid split clone contract for a given ipId + address claimer; // address of the claimer contract for a given ipId + uint32 royaltyStack; // royalty stack for a given ipId is the sum of the minRoyalty of all its parents (number between 0 and 1000) + uint32 minRoyalty; // minimum royalty the ipId will receive from its children and grandchildren (number between 0 and 1000) + } + + /// @notice Percentage scale - 1000 rnfts represents 100% + uint32 public constant TOTAL_RNFT_SUPPLY = 1000; + /// @notice RoyaltyModule address address public immutable ROYALTY_MODULE; + /// @notice License registry address + address public immutable LICENSE_REGISTRY; + /// @notice LiquidSplitFactory address address public immutable LIQUID_SPLIT_FACTORY; /// @notice LiquidSplitMain address address public immutable LIQUID_SPLIT_MAIN; - /// @notice Indicates the address of the LiquidSplitClone contract for a given ipId - mapping(address ipId => address splitClone) public splitClones; + /// @notice Links the ipId to its royalty data + mapping(address ipId => LSRoyaltyData) public royaltyData; /// @notice Restricts the calls to the royalty module modifier onlyRoyaltyModule() { @@ -38,37 +53,66 @@ contract RoyaltyPolicyLS is IRoyaltyPolicy { /// @notice Constructor /// @param _royaltyModule Address of the RoyaltyModule contract + /// @param _licenseRegistry Address of the LicenseRegistry contract /// @param _liquidSplitFactory Address of the LiquidSplitFactory contract /// @param _liquidSplitMain Address of the LiquidSplitMain contract - constructor(address _royaltyModule, address _liquidSplitFactory, address _liquidSplitMain) { + constructor(address _royaltyModule, address _licenseRegistry, address _liquidSplitFactory, address _liquidSplitMain) { if (_royaltyModule == address(0)) revert Errors.RoyaltyPolicyLS__ZeroRoyaltyModule(); + if (_licenseRegistry == address(0)) revert Errors.RoyaltyPolicyLS__ZeroLicenseRegistry(); if (_liquidSplitFactory == address(0)) revert Errors.RoyaltyPolicyLS__ZeroLiquidSplitFactory(); if (_liquidSplitMain == address(0)) revert Errors.RoyaltyPolicyLS__ZeroLiquidSplitMain(); ROYALTY_MODULE = _royaltyModule; + LICENSE_REGISTRY = _licenseRegistry; LIQUID_SPLIT_FACTORY = _liquidSplitFactory; LIQUID_SPLIT_MAIN = _liquidSplitMain; } + // TODO: Ensure that parentsIds should be correctly passed in through the licensing contract, otherwise we must call parents() on licenseRegistry directly + // TODO: setApprovalForAll for splitClone to this contract to allow it to transfer RNFTs? Useful for the corner case where someone holds all rnfts /// @notice Initializes the royalty policy /// @param _ipId The ipId + /// @param _parentIpIds The parent ipIds /// @param _data The data to initialize the policy - function initPolicy(address _ipId, bytes calldata _data) external onlyRoyaltyModule { - (address[] memory accounts, uint32[] memory initAllocations, uint32 distributorFee, address splitOwner) = abi - .decode(_data, (address[], uint32[], uint32, address)); - - // TODO: input validation: accounts & initAllocations - can we make up to 1000 parents with tx going through - if not alternative may be to create new contract to claim RNFTs - // TODO: input validation: distributorFee - // TODO: input validation: splitOwner - - address splitClone = ILiquidSplitFactory(LIQUID_SPLIT_FACTORY).createLiquidSplitClone( - accounts, - initAllocations, - distributorFee, - splitOwner - ); + function initPolicy(address _ipId, address[] calldata _parentIpIds, bytes calldata _data) external onlyRoyaltyModule { + (uint32 minRoyalty) = abi.decode(_data, (uint32)); + // root you can choose 0% but children have to choose at least 1% + if (minRoyalty == 0 && _parentIpIds.length > 0) revert Errors.RoyaltyPolicyLS__ZeroMinRoyalty(); + // minRoyalty has to be a multiple of 1% and given that there are 1000 royalty nfts + // then minRoyalty has to be a multiple of 10 + if (minRoyalty % 10 != 0) revert Errors.RoyaltyPolicyLS__InvalidMinRoyalty(); + + // calculates the new royalty stack and checks if it is valid + (uint32 royaltyStack, uint32 newRoyaltyStack) = _checkRoyaltyStackIsValid(_parentIpIds, minRoyalty); + + // deploy claimer if not root ip + address claimer = address(this); // 0xSplit requires two addresses to allow a split so for root ip address(this) as the second address + if (_parentIpIds.length > 0) claimer = address(new LSClaimer(_ipId, LICENSE_REGISTRY, address(this))); + + // deploy split clone + address splitClone = _deploySplitClone(_ipId, claimer, royaltyStack); + + royaltyData[_ipId] = LSRoyaltyData({ + splitClone: splitClone, + claimer: claimer, + royaltyStack: newRoyaltyStack, + minRoyalty: minRoyalty + }); + } - splitClones[_ipId] = splitClone; + /// @notice Allows to pay a royalty + /// @param _caller The caller + /// @param _ipId The ipId + /// @param _token The token to pay + /// @param _amount The amount to pay + function onRoyaltyPayment( + address _caller, + address _ipId, + address _token, + uint256 _amount + ) external onlyRoyaltyModule { + address destination = royaltyData[_ipId].splitClone; + IERC20(_token).safeTransferFrom(_caller, destination, _amount); } /// @notice Distributes funds to the accounts in the LiquidSplitClone contract @@ -82,7 +126,7 @@ contract RoyaltyPolicyLS is IRoyaltyPolicy { address[] calldata _accounts, address _distributorAddress ) external { - ILiquidSplitClone(splitClones[_ipId]).distributeFunds(_token, _accounts, _distributorAddress); + ILiquidSplitClone(royaltyData[_ipId].splitClone).distributeFunds(_token, _accounts, _distributorAddress); } /// @notice Claims the available royalties for a given account @@ -93,18 +137,46 @@ contract RoyaltyPolicyLS is IRoyaltyPolicy { ILiquidSplitMain(LIQUID_SPLIT_MAIN).withdraw(_account, _withdrawETH, _tokens); } - /// @notice Allows to pay a royalty - /// @param _caller The caller + /// @notice Checks if the royalty stack is valid + /// @param _parentIpIds The parent ipIds + /// @param _minRoyalty The minimum royalty + /// @return royaltyStack The royalty stack + /// newRoyaltyStack The new royalty stack + function _checkRoyaltyStackIsValid(address[] calldata _parentIpIds, uint32 _minRoyalty) internal view returns (uint32, uint32) { + // the loop below is limited to a length of 100 parents + // given the minimum royalty step of 1% and a cap of 100% + uint32 royaltyStack; + for (uint32 i = 0; i < _parentIpIds.length; i++) { + royaltyStack += royaltyData[_parentIpIds[i]].royaltyStack; + } + + uint32 newRoyaltyStack = royaltyStack + _minRoyalty; + if (newRoyaltyStack > TOTAL_RNFT_SUPPLY) revert Errors.RoyaltyPolicyLS__InvalidRoyaltyStack(); + + return (royaltyStack, newRoyaltyStack); + } + + /// @notice Deploys a liquid split clone contract /// @param _ipId The ipId - /// @param _token The token to pay - /// @param _amount The amount to pay - function onRoyaltyPayment( - address _caller, - address _ipId, - address _token, - uint256 _amount - ) external onlyRoyaltyModule { - address destination = splitClones[_ipId]; - IERC20(_token).safeTransferFrom(_caller, destination, _amount); + /// @param _claimer The claimer address + /// @param royaltyStack The number of rnfts that the ipId has to give to its parents and/or grandparents + /// @return The address of the deployed liquid split clone contract + function _deploySplitClone(address _ipId, address _claimer, uint32 royaltyStack) internal returns (address) { + address[] memory accounts = new address[](2); + accounts[0] = _ipId; + accounts[1] = _claimer; + + uint32[] memory initAllocations = new uint32[](2); + initAllocations[0] = TOTAL_RNFT_SUPPLY - royaltyStack; + initAllocations[1] = royaltyStack; + + address splitClone = ILiquidSplitFactory(LIQUID_SPLIT_FACTORY).createLiquidSplitClone( + accounts, + initAllocations, + 0, // distributorFee + address(0) // splitOwner + ); + + return splitClone; } } diff --git a/test/foundry/RoyaltyModule.t.sol b/test/foundry/RoyaltyModule.t.sol deleted file mode 100644 index dcd9e28da..000000000 --- a/test/foundry/RoyaltyModule.t.sol +++ /dev/null @@ -1,157 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.23; - -import {console2} from "forge-std/console2.sol"; -import {TestHelper} from "./../utils/TestHelper.sol"; - -import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; -import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; - -contract TestRoyaltyModule is TestHelper { - function setUp() public override { - super.setUp(); - - // fund USDC - vm.startPrank(USDC_RICH); - IERC20(USDC).transfer(ipAccount4, 1000 * 10 ** 6); - vm.stopPrank(); - - // whitelist royalty policy - royaltyModule.whitelistRoyaltyPolicy(address(royaltyPolicyLS), true); - } - - function test_RoyaltyModule_whitelistRoyaltyPolicy() public { - assertEq(royaltyModule.isWhitelistedRoyaltyPolicy(address(royaltyPolicyLS)), true); - } - - function test_RoyaltyModule_setRoyalty() public { - vm.startPrank(ipAccount3); - - address[] memory accounts = new address[](2); - accounts[0] = ipAccount1; - accounts[1] = ipAccount2; - - uint32[] memory initAllocations = new uint32[](2); - initAllocations[0] = 100; - initAllocations[1] = 900; - - bytes memory data = abi.encode(accounts, initAllocations, uint32(0), address(0)); - - royaltyModule.setRoyaltyPolicy(ipAccount3, address(royaltyPolicyLS), data); - - assertEq(royaltyModule.royaltyPolicies(ipAccount3), address(royaltyPolicyLS)); - // TODO: assertNotEq(royaltyModule.royaltyPolicies(ipAccount3), address(0)); // assertNotEq was deprecated? - } - - function test_RoyaltyModule_payRoyalty() public { - vm.startPrank(ipAccount3); - - address[] memory accounts = new address[](2); - accounts[0] = ipAccount1; - accounts[1] = ipAccount2; - - uint32[] memory initAllocations = new uint32[](2); - initAllocations[0] = 100; - initAllocations[1] = 900; - - bytes memory data = abi.encode(accounts, initAllocations, uint32(0), address(0)); - - royaltyModule.setRoyaltyPolicy(ipAccount3, address(royaltyPolicyLS), data); - vm.stopPrank(); - - vm.startPrank(ipAccount4); - uint256 royaltyAmount = 100 * 10 ** 6; - IERC20(USDC).approve(address(royaltyPolicyLS), royaltyAmount); - - uint256 ipAccount4USDCBalanceBefore = IERC20(USDC).balanceOf(ipAccount4); - uint256 splitCloneUSDCBalanceBefore = IERC20(USDC).balanceOf(royaltyPolicyLS.splitClones(ipAccount3)); - - royaltyModule.payRoyalty(ipAccount3, USDC, royaltyAmount); - - uint256 ipAccount4USDCBalanceAfter = IERC20(USDC).balanceOf(ipAccount4); - uint256 splitCloneUSDCBalanceAfter = IERC20(USDC).balanceOf(royaltyPolicyLS.splitClones(ipAccount3)); - - assertEq(ipAccount4USDCBalanceBefore - ipAccount4USDCBalanceAfter, royaltyAmount); - assertEq(splitCloneUSDCBalanceAfter - splitCloneUSDCBalanceBefore, royaltyAmount); - } - - // TODO: move to royalty policy test file when created - function test_RoyaltyModule_distributeFunds() public { - vm.startPrank(ipAccount3); - - address[] memory accounts = new address[](2); - accounts[0] = ipAccount1; - accounts[1] = ipAccount2; - - uint32[] memory initAllocations = new uint32[](2); - initAllocations[0] = 100; - initAllocations[1] = 900; - - bytes memory data = abi.encode(accounts, initAllocations, uint32(0), address(0)); - - royaltyModule.setRoyaltyPolicy(ipAccount3, address(royaltyPolicyLS), data); - vm.stopPrank(); - - vm.startPrank(ipAccount4); - uint256 royaltyAmount = 100 * 10 ** 6; - IERC20(USDC).approve(address(royaltyPolicyLS), royaltyAmount); - - royaltyModule.payRoyalty(ipAccount3, USDC, royaltyAmount); - vm.stopPrank(); - - vm.startPrank(ipAccount2); - - uint256 splitCloneUSDCBalanceBefore = IERC20(USDC).balanceOf(royaltyPolicyLS.splitClones(ipAccount3)); - uint256 splitMainUSDCBalanceBefore = IERC20(USDC).balanceOf(royaltyPolicyLS.LIQUID_SPLIT_MAIN()); - - royaltyPolicyLS.distributeFunds(ipAccount3, USDC, accounts, address(2)); - - uint256 splitCloneUSDCBalanceAfter = IERC20(USDC).balanceOf(royaltyPolicyLS.splitClones(ipAccount3)); - uint256 splitMainUSDCBalanceAfter = IERC20(USDC).balanceOf(royaltyPolicyLS.LIQUID_SPLIT_MAIN()); - - assertApproxEqRel(splitCloneUSDCBalanceBefore - splitCloneUSDCBalanceAfter, royaltyAmount, 0.0001e18); - assertApproxEqRel(splitMainUSDCBalanceAfter - splitMainUSDCBalanceBefore, royaltyAmount, 0.0001e18); - } - - // TODO: move to royalty policy test file when created - function test_RoyaltyModule_claimRoyalties() public { - vm.startPrank(ipAccount3); - - address[] memory accounts = new address[](2); - accounts[0] = ipAccount1; - accounts[1] = ipAccount2; - - uint32[] memory initAllocations = new uint32[](2); - initAllocations[0] = 100; - initAllocations[1] = 900; - - bytes memory data = abi.encode(accounts, initAllocations, uint32(0), address(0)); - - royaltyModule.setRoyaltyPolicy(ipAccount3, address(royaltyPolicyLS), data); - vm.stopPrank(); - - vm.startPrank(ipAccount4); - uint256 royaltyAmount = 100 * 10 ** 6; - IERC20(USDC).approve(address(royaltyPolicyLS), royaltyAmount); - - royaltyModule.payRoyalty(ipAccount3, USDC, royaltyAmount); - vm.stopPrank(); - - vm.startPrank(ipAccount2); - royaltyPolicyLS.distributeFunds(ipAccount3, USDC, accounts, address(2)); - - ERC20[] memory tokens = new ERC20[](1); - tokens[0] = ERC20(USDC); - - uint256 ipAccount2USDCBalanceBefore = IERC20(USDC).balanceOf(ipAccount2); - uint256 splitMainUSDCBalanceBefore = IERC20(USDC).balanceOf(royaltyPolicyLS.LIQUID_SPLIT_MAIN()); - - royaltyPolicyLS.claimRoyalties(ipAccount2, 0, tokens); - - uint256 ipAccount2USDCBalanceAfter = IERC20(USDC).balanceOf(ipAccount2); - uint256 splitMainUSDCBalanceAfter = IERC20(USDC).balanceOf(royaltyPolicyLS.LIQUID_SPLIT_MAIN()); - - assertApproxEqRel(ipAccount2USDCBalanceAfter - ipAccount2USDCBalanceBefore, 90 * 10 ** 6, 0.0001e18); - assertApproxEqRel(splitMainUSDCBalanceBefore - splitMainUSDCBalanceAfter, 90 * 10 ** 6, 0.0001e18); - } -} diff --git a/test/foundry/integration/BaseIntegration.sol b/test/foundry/integration/BaseIntegration.sol index fc8523e0f..c8a4e7790 100644 --- a/test/foundry/integration/BaseIntegration.sol +++ b/test/foundry/integration/BaseIntegration.sol @@ -141,7 +141,7 @@ contract BaseIntegration is Test { ); arbitrationPolicySP = new ArbitrationPolicySP(address(disputeModule), USDC, ARBITRATION_PRICE); - royaltyPolicyLS = new RoyaltyPolicyLS(address(royaltyModule), LIQUID_SPLIT_FACTORY, LIQUID_SPLIT_MAIN); + royaltyPolicyLS = new RoyaltyPolicyLS(address(royaltyModule), address(licenseRegistry), LIQUID_SPLIT_FACTORY, LIQUID_SPLIT_MAIN); } function _configDeployedContracts() internal { diff --git a/test/foundry/integration/big-bang/SingleNftCollection.t.sol b/test/foundry/integration/big-bang/SingleNftCollection.t.sol index e4c1a19fd..d1587967b 100644 --- a/test/foundry/integration/big-bang/SingleNftCollection.t.sol +++ b/test/foundry/integration/big-bang/SingleNftCollection.t.sol @@ -11,7 +11,7 @@ import { IIPAccount } from "contracts/interfaces/IIPAccount.sol"; import { BaseIntegration } from "test/foundry/integration/BaseIntegration.sol"; import { MintPaymentPolicyFrameworkManager } from "test/foundry/mocks/licensing/MintPaymentPolicyFrameworkManager.sol"; import { MockERC721 } from "test/foundry/mocks/MockERC721.sol"; -import { Integration_Shared_LicensingHelper, UMLPolicyGenericParams, UMLPolicyCommercialParams, UMLPolicyDerivativeParams } from "test/foundry/integration/shared/LicensingHelper.sol"; +import { Integration_Shared_LicensingHelper, UMLPolicyGenericParams, UMLPolicyCommercialParams, UMLPolicyDerivativeParams } from "test/foundry/integration/shared/LicenseHelper.sol"; contract BigBang_Integration_SingleNftCollection is BaseIntegration, Integration_Shared_LicensingHelper { using EnumerableSet for EnumerableSet.UintSet; diff --git a/test/foundry/integration/shared/LicensingHelper.sol b/test/foundry/integration/shared/LicenseHelper.sol similarity index 100% rename from test/foundry/integration/shared/LicensingHelper.sol rename to test/foundry/integration/shared/LicenseHelper.sol diff --git a/test/foundry/modules/royalty/LSClaimer.t.sol b/test/foundry/modules/royalty/LSClaimer.t.sol new file mode 100644 index 000000000..17ea1c704 --- /dev/null +++ b/test/foundry/modules/royalty/LSClaimer.t.sol @@ -0,0 +1,298 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +// external +import { console2 } from "forge-std/console2.sol"; +import { ERC20, IERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import { Strings } from "@openzeppelin/contracts/utils/Strings.sol"; +import { ERC6551AccountLib } from "lib/reference/src/lib/ERC6551AccountLib.sol"; + +// contracts +import { ILiquidSplitFactory } from "contracts/interfaces/modules/royalty/policies/ILiquidSplitFactory.sol"; +import { Errors } from "contracts/lib/Errors.sol"; +import { BasePolicyFrameworkManager } from "contracts/modules/licensing/BasePolicyFrameworkManager.sol"; +import { UMLPolicyFrameworkManager } from "contracts/modules/licensing/UMLPolicyFrameworkManager.sol"; +import { ILiquidSplitClone } from "contracts/interfaces/modules/royalty/policies/ILiquidSplitClone.sol"; +import { ILiquidSplitMain } from "contracts/interfaces/modules/royalty/policies/ILiquidSplitMain.sol"; +import { LSClaimer } from "contracts/modules/royalty-module/policies/LSClaimer.sol"; +import { RoyaltyPolicyLS } from "contracts/modules/royalty-module/policies/RoyaltyPolicyLS.sol"; + +// test +import { UMLPolicyGenericParams, UMLPolicyCommercialParams, UMLPolicyDerivativeParams } from "test/foundry/integration/shared/LicenseHelper.sol"; +import { MintPaymentPolicyFrameworkManager } from "test/foundry/mocks/licensing/MintPaymentPolicyFrameworkManager.sol"; +import { MockERC721 } from "test/foundry/mocks/MockERC721.sol"; +import { TestHelper } from "test/utils/TestHelper.sol"; + +contract TestLSClaimer is TestHelper { + address[] public LONG_CHAIN = new address[](100); + address[] public accounts = new address[](2); + uint32[] public initAllocations = new uint32[](2); + address public splitClone100; + LSClaimer public lsClaimer100; + uint32 public royaltyStack100; + uint32 public minRoyalty100; + ERC20[] public tokens = new ERC20[](1); + uint256 public ethRoyaltyAmount = 1 ether; + uint256 public usdcRoyaltyAmount = 1000 * 10 ** 6; + + function setUp() public override { + TestHelper.setUp(); + _setUMLPolicyFrameworkManager(); + nft = new MockERC721("mock"); + _addUMLPolicy( + true, + true, + UMLPolicyGenericParams({ + policyName: "cheap_flexible", // => uml_cheap_flexible + attribution: false, + transferable: true, + territories: new string[](0), + distributionChannels: new string[](0) + }), + UMLPolicyCommercialParams({ + commercialAttribution: true, + commercializers: new string[](0), + commercialRevShare: 10 + }), + UMLPolicyDerivativeParams({ + derivativesAttribution: true, + derivativesApproval: false, + derivativesReciprocal: false, + derivativesRevShare: 10 + }) + ); + + _setLsClaimer(); + } + + function _setLsClaimer() internal { + vm.startPrank(deployer); + for (uint256 i = 0; i < 101; i++) { + nft.mintId(deployer, i); + nftIds.push(i); + } + + address expectedAddr = ERC6551AccountLib.computeAddress( + address(erc6551Registry), + address(ipAccountImpl), + ipAccountRegistry.IP_ACCOUNT_SALT(), + block.chainid, + address(nft), + nftIds[0] + ); + vm.label(expectedAddr, string(abi.encodePacked("IPAccount", Strings.toString(nftIds[0])))); + + address ipAddr = registrationModule.registerRootIp(policyIds["uml_cheap_flexible"], address(nft), nftIds[0]); + vm.label(ipAddr, string(abi.encodePacked("IPAccount", Strings.toString(nftIds[0])))); + + for (uint256 i = 0; i < 99; i++) { + uint256 licenseId = licenseRegistry.mintLicense( + policyIds["uml_cheap_flexible"], + _getIpId(nft, nftIds[i]), + 2, + deployer + ); + + address expectedAddr = ERC6551AccountLib.computeAddress( + address(erc6551Registry), + address(ipAccountImpl), + ipAccountRegistry.IP_ACCOUNT_SALT(), + block.chainid, + address(nft), + nftIds[i + 1] + ); + vm.label(expectedAddr, string(abi.encodePacked("IPAccount", Strings.toString(nftIds[i + 1])))); + + registrationModule.registerDerivativeIp(licenseId, address(nft), nftIds[i + 1], "", bytes32(""), ""); + } + + // /*/////////////////////////////////////////////////////////////// + // SET UP LSCLAIMER + // ////////////////////////////////////////////////////////////////*/ + + RoyaltyPolicyLS testRoyaltyPolicyLS; + testRoyaltyPolicyLS = new RoyaltyPolicyLS( + address(1), + address(licenseRegistry), + LIQUID_SPLIT_FACTORY, + LIQUID_SPLIT_MAIN + ); + + vm.startPrank(address(1)); + // set up root royalty policy + address[] memory parentsIds1 = new address[](1); + testRoyaltyPolicyLS.initPolicy(_getIpId(nft, nftIds[0]), parentsIds1, abi.encode(10)); + + // set up derivative royalty policy + for (uint256 i = 0; i < 99; i++) { + address[] memory parentsIds = new address[](1); + parentsIds[0] = _getIpId(nft, nftIds[i]); + testRoyaltyPolicyLS.initPolicy(_getIpId(nft, nftIds[i + 1]), parentsIds, abi.encode(10)); + } + vm.stopPrank(); + + (address split, address claimer, uint32 rStack, uint32 mRoyalty) = testRoyaltyPolicyLS.royaltyData( + _getIpId(nft, nftIds[99]) + ); + lsClaimer100 = LSClaimer(claimer); + splitClone100 = split; + royaltyStack100 = rStack; + minRoyalty100 = mRoyalty; + + // set up longest chain possible of 100 elements + for (uint256 i = 0; i < 100; i++) { + LONG_CHAIN[i] = _getIpId(nft, nftIds[i]); + } + assertEq(LONG_CHAIN[0], _getIpId(nft, nftIds[0])); + assertEq(LONG_CHAIN[99], _getIpId(nft, nftIds[99])); + assertEq(LONG_CHAIN.length, 100); + assertEq(royaltyStack100, 1000); + } + + function test_LSClaimer_constructor_revert_ZeroIpId() public { + vm.expectRevert(Errors.LSClaimer__ZeroIpId.selector); + new LSClaimer(address(0), address(licenseRegistry), address(royaltyPolicyLS)); + } + + function test_LSClaimer_constructor_revert_ZeroLicenseRegistry() public { + vm.expectRevert(Errors.LSClaimer__ZeroLicenseRegistry.selector); + new LSClaimer(address(1), address(0), address(royaltyPolicyLS)); + } + + function test_LSClaimer_constructor_revert_ZeroRoyaltyPolicyLS() public { + vm.expectRevert(Errors.LSClaimer__ZeroRoyaltyPolicyLS.selector); + new LSClaimer(address(1), address(licenseRegistry), address(0)); + } + + function test_LSClaimer_constructor() public { + LSClaimer testLsClaimer = new LSClaimer(address(1), address(licenseRegistry), address(royaltyPolicyLS)); + + assertEq(address(testLsClaimer.IP_ID()), address(1)); + assertEq(address(testLsClaimer.ILICENSE_REGISTRY()), address(licenseRegistry)); + assertEq(address(testLsClaimer.IROYALTY_POLICY_LS()), address(royaltyPolicyLS)); + } + + function test_LSClaimer_claim_revert_AlreadyClaimed() public { + address claimerIpId = _getIpId(nft, nftIds[0]); + tokens[0] = ERC20(USDC); + + lsClaimer100.claim(LONG_CHAIN, claimerIpId, true, tokens); + + vm.expectRevert(Errors.LSClaimer__AlreadyClaimed.selector); + lsClaimer100.claim(LONG_CHAIN, claimerIpId, true, tokens); + } + + function test_LSClaimer_claim_revert_InvalidPathFirstPosition() public { + tokens[0] = ERC20(USDC); + + vm.expectRevert(Errors.LSClaimer__InvalidPathFirstPosition.selector); + lsClaimer100.claim(LONG_CHAIN, address(0), true, tokens); + } + + function test_LSClaimer_claim_revert_InvalidPathLastPosition() public { + address claimerIpId = _getIpId(nft, nftIds[0]); + tokens[0] = ERC20(USDC); + + LONG_CHAIN.push(address(1)); + + vm.expectRevert(Errors.LSClaimer__InvalidPathLastPosition.selector); + lsClaimer100.claim(LONG_CHAIN, claimerIpId, true, tokens); + } + + function test_LSClaimer_claim_revert_InvalidPath() public { + address claimerIpId = _getIpId(nft, nftIds[0]); + tokens[0] = ERC20(USDC); + + LONG_CHAIN[5] = address(1); + + vm.expectRevert(Errors.LSClaimer__InvalidPath.selector); + lsClaimer100.claim(LONG_CHAIN, claimerIpId, true, tokens); + } + + function test_LSClaimer_claim_revert_ERC20BalanceNotZero() public { + vm.startPrank(USDC_RICH); + IERC20(USDC).transfer(address(splitClone100), usdcRoyaltyAmount); + vm.stopPrank(); + + accounts[0] = _getIpId(nft, nftIds[99]); + accounts[1] = address(lsClaimer100); + + ILiquidSplitClone(splitClone100).distributeFunds(USDC, accounts, address(0)); + + ERC20 token_ = ERC20(USDC); + assertGt( + ILiquidSplitMain(royaltyPolicyLS.LIQUID_SPLIT_MAIN()).getERC20Balance(address(lsClaimer100), token_), + 0 + ); + + address claimerIpId = _getIpId(nft, nftIds[0]); + tokens[0] = ERC20(USDC); + + vm.expectRevert(Errors.LSClaimer__ERC20BalanceNotZero.selector); + lsClaimer100.claim(LONG_CHAIN, claimerIpId, true, tokens); + } + + function test_LSClaimer_claim_revert_ETHBalanceNotZero() public { + vm.deal(address(splitClone100), ethRoyaltyAmount); + + accounts[0] = _getIpId(nft, nftIds[99]); + accounts[1] = address(lsClaimer100); + + ILiquidSplitClone(splitClone100).distributeFunds(address(0), accounts, address(0)); + + assertGt(ILiquidSplitMain(royaltyPolicyLS.LIQUID_SPLIT_MAIN()).getETHBalance(address(lsClaimer100)), 0); + + address claimerIpId = _getIpId(nft, nftIds[0]); + tokens[0] = ERC20(USDC); + + vm.expectRevert(Errors.LSClaimer__ETHBalanceNotZero.selector); + lsClaimer100.claim(LONG_CHAIN, claimerIpId, true, tokens); + } + + function test_LSClaimer_claim() public { + // claimer contract receives royalties + vm.deal(address(lsClaimer100), ethRoyaltyAmount); + vm.startPrank(USDC_RICH); + IERC20(USDC).transfer(address(lsClaimer100), usdcRoyaltyAmount); + vm.stopPrank(); + + address claimerIpId = _getIpId(nft, nftIds[0]); + tokens[0] = ERC20(USDC); + + uint256 lsClaimerUSDCBalBefore = IERC20(USDC).balanceOf(address(lsClaimer100)); + uint256 lsClaimerETHBalBefore = address(lsClaimer100).balance; + uint256 lsClaimerRNFTBalBefore = ILiquidSplitClone(splitClone100).balanceOf(address(lsClaimer100), 0); + uint256 claimerUSDCBalBefore = IERC20(USDC).balanceOf(claimerIpId); + uint256 claimerETHBalBefore = address(claimerIpId).balance; + uint256 claimerRNFTBalBefore = ILiquidSplitClone(splitClone100).balanceOf(claimerIpId, 0); + + lsClaimer100.claim(LONG_CHAIN, claimerIpId, true, tokens); + + uint256 lsClaimerUSDCBalAfter = IERC20(USDC).balanceOf(address(lsClaimer100)); + uint256 lsClaimerETHBalAfter = address(lsClaimer100).balance; + uint256 lsClaimerRNFTBalAfter = ILiquidSplitClone(splitClone100).balanceOf(address(lsClaimer100), 0); + uint256 claimerUSDCBalAfter = IERC20(USDC).balanceOf(claimerIpId); + uint256 claimerETHBalAfter = address(claimerIpId).balance; + uint256 claimerRNFTBalAfter = ILiquidSplitClone(splitClone100).balanceOf(claimerIpId, 0); + + assertEq(lsClaimer100.claimedPaths(keccak256(abi.encodePacked(LONG_CHAIN))), true); + assertEq( + lsClaimerUSDCBalBefore - lsClaimerUSDCBalAfter, + (usdcRoyaltyAmount * minRoyalty100) / (royaltyStack100 - minRoyalty100) + ); // calculation not aggregated in a variable due to stack too deep error + assertEq( + lsClaimerETHBalBefore - lsClaimerETHBalAfter, + (ethRoyaltyAmount * minRoyalty100) / (royaltyStack100 - minRoyalty100) + ); + assertEq(lsClaimerRNFTBalBefore - lsClaimerRNFTBalAfter, minRoyalty100); + assertEq( + claimerUSDCBalAfter - claimerUSDCBalBefore, + (usdcRoyaltyAmount * minRoyalty100) / (royaltyStack100 - minRoyalty100) + ); + assertEq( + claimerETHBalAfter - claimerETHBalBefore, + (ethRoyaltyAmount * minRoyalty100) / (royaltyStack100 - minRoyalty100) + ); + assertEq(claimerRNFTBalAfter - claimerRNFTBalBefore, minRoyalty100); + } +} diff --git a/test/foundry/modules/royalty/RoyaltyModule.t.sol b/test/foundry/modules/royalty/RoyaltyModule.t.sol new file mode 100644 index 000000000..7a6478ac2 --- /dev/null +++ b/test/foundry/modules/royalty/RoyaltyModule.t.sol @@ -0,0 +1,183 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.23; + + +import {console2} from "forge-std/console2.sol"; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +import { TestHelper } from "../../../utils/TestHelper.sol"; +import { Errors } from "contracts/lib/Errors.sol"; + +contract TestRoyaltyModule is TestHelper { + event RoyaltyPolicyWhitelistUpdated(address royaltyPolicy, bool allowed); + event RoyaltyTokenWhitelistUpdated(address token, bool allowed); + event RoyaltyPolicySet(address ipId, address royaltyPolicy, bytes data); + event RoyaltyPaid(address receiverIpId, address payerIpId, address sender, address token, uint256 amount); + + function setUp() public override { + super.setUp(); + + // fund USDC + vm.startPrank(USDC_RICH); + IERC20(USDC).transfer(ipAccount2, 1000 * 10 ** 6); // 1000 USDC + vm.stopPrank(); + + // whitelist royalty policy + royaltyModule.whitelistRoyaltyPolicy(address(royaltyPolicyLS), true); + + // whitelist royalty token + royaltyModule.whitelistRoyaltyToken(USDC, true); + } + + function test_RoyaltyModule_whitelistRoyaltyPolicy_revert_ZeroRoyaltyToken() public { + vm.expectRevert(Errors.RoyaltyModule__ZeroRoyaltyToken.selector); + + royaltyModule.whitelistRoyaltyToken(address(0), true); + } + + function test_RoyaltyModule_whitelistRoyaltyPolicy() public { + assertEq(royaltyModule.isWhitelistedRoyaltyPolicy(address(1)), false); + + vm.expectEmit(true, true, true, true, address(royaltyModule)); + emit RoyaltyPolicyWhitelistUpdated(address(1), true); + + royaltyModule.whitelistRoyaltyPolicy(address(1), true); + + assertEq(royaltyModule.isWhitelistedRoyaltyPolicy(address(1)), true); + } + + function test_RoyaltyModule_whitelistRoyaltyToken_revert_ZeroRoyaltyPolicy() public { + vm.expectRevert(Errors.RoyaltyModule__ZeroRoyaltyPolicy.selector); + + royaltyModule.whitelistRoyaltyPolicy(address(0), true); + } + + function test_RoyaltyModule_whitelistRoyaltyToken() public { + assertEq(royaltyModule.isWhitelistedRoyaltyToken(address(1)), false); + + vm.expectEmit(true, true, true, true, address(royaltyModule)); + emit RoyaltyTokenWhitelistUpdated(address(1), true); + + royaltyModule.whitelistRoyaltyToken(address(1), true); + + assertEq(royaltyModule.isWhitelistedRoyaltyToken(address(1)), true); + } + + function test_RoyaltyModule_setRoyaltyPolicy_revert_AlreadySetRoyaltyPolicy() public { + address[] memory parentIpIds1 = new address[](0); + uint32 minRoyaltyIpAccount1 = 100; // 10% + bytes memory data = abi.encode(minRoyaltyIpAccount1); + + royaltyModule.setRoyaltyPolicy(ipAccount1, address(royaltyPolicyLS), parentIpIds1, data); + + vm.expectRevert(Errors.RoyaltyModule__AlreadySetRoyaltyPolicy.selector); + royaltyModule.setRoyaltyPolicy(ipAccount1, address(royaltyPolicyLS), parentIpIds1, data); + } + + function test_RoyaltyModule_setRoyaltyPolicy_revert_NotWhitelistedRoyaltyPolicy() public { + vm.expectRevert(Errors.RoyaltyModule__NotWhitelistedRoyaltyPolicy.selector); + + address[] memory parentIpIds1 = new address[](0); + uint32 minRoyaltyIpAccount1 = 100; // 10% + bytes memory data = abi.encode(minRoyaltyIpAccount1); + + royaltyModule.setRoyaltyPolicy(ipAccount1, address(1), parentIpIds1, data); + } + + function test_RoyaltyModule_setRoyaltyPolicy_revert_IncompatibleRoyaltyPolicy() public { + address[] memory parentIpIds1 = new address[](0); + uint32 minRoyaltyIpAccount1 = 100; // 10% + bytes memory data1 = abi.encode(minRoyaltyIpAccount1); + + royaltyModule.setRoyaltyPolicy(ipAccount1, address(royaltyPolicyLS), parentIpIds1, data1); + + address[] memory parentIpIds2 = new address[](1); + parentIpIds2[0] = ipAccount1; + uint32 minRoyaltyIpAccount2 = 100; // 10% + bytes memory data2 = abi.encode(minRoyaltyIpAccount2); + + royaltyModule.whitelistRoyaltyPolicy(address(1), true); + + vm.expectRevert(Errors.RoyaltyModule__IncompatibleRoyaltyPolicy.selector); + royaltyModule.setRoyaltyPolicy(ipAccount2, address(1), parentIpIds2, data2); + } + + function test_RoyaltyModule_setRoyaltyPolicy() public { + address[] memory parentIpIds1 = new address[](0); + uint32 minRoyaltyIpAccount1 = 100; // 10% + bytes memory data = abi.encode(minRoyaltyIpAccount1); + + vm.expectEmit(true, true, true, true, address(royaltyModule)); + emit RoyaltyPolicySet(ipAccount1, address(royaltyPolicyLS), data); + + royaltyModule.setRoyaltyPolicy(ipAccount1, address(royaltyPolicyLS), parentIpIds1, data); + + assertEq(royaltyModule.royaltyPolicies(ipAccount1), address(royaltyPolicyLS)); + } + + function test_RoyaltyModule_payRoyaltyOnBehalf_revert_NoRoyaltyPolicySet() public { + vm.expectRevert(Errors.RoyaltyModule__NoRoyaltyPolicySet.selector); + + royaltyModule.payRoyaltyOnBehalf(ipAccount1, ipAccount2, USDC, 100); + } + + function test_RoyaltyModule_payRoyaltyOnBehalf_revert_NotWhitelistedRoyaltyToken() public { + address[] memory parentIpIds1 = new address[](0); + uint32 minRoyaltyIpAccount1 = 100; // 10% + bytes memory data = abi.encode(minRoyaltyIpAccount1); + + royaltyModule.setRoyaltyPolicy(ipAccount1, address(royaltyPolicyLS), parentIpIds1, data); + + vm.expectRevert(Errors.RoyaltyModule__NotWhitelistedRoyaltyToken.selector); + royaltyModule.payRoyaltyOnBehalf(ipAccount1, ipAccount2, address(1), 100); + } + + function test_RoyaltyModule_payRoyaltyOnBehalf_revert_NotWhitelistedRoyaltyPolicy() public { + address[] memory parentIpIds1 = new address[](0); + uint32 minRoyaltyIpAccount1 = 100; // 10% + bytes memory data = abi.encode(minRoyaltyIpAccount1); + + royaltyModule.setRoyaltyPolicy(ipAccount1, address(royaltyPolicyLS), parentIpIds1, data); + + royaltyModule.whitelistRoyaltyPolicy(address(royaltyPolicyLS), false); + + vm.expectRevert(Errors.RoyaltyModule__NotWhitelistedRoyaltyPolicy.selector); + royaltyModule.payRoyaltyOnBehalf(ipAccount1, ipAccount2, USDC, 100); + } + + function test_RoyaltyModule_payRoyaltyOnBehalf() public { + uint256 royaltyAmount = 100 * 10 ** 6; // 100 USDC + + address[] memory parentIpIds1 = new address[](0); + uint32 minRoyaltyIpAccount1 = 100; // 10% + bytes memory data1 = abi.encode(minRoyaltyIpAccount1); + + royaltyModule.setRoyaltyPolicy(ipAccount1, address(royaltyPolicyLS), parentIpIds1, data1); + + address[] memory parentIpIds2 = new address[](0); + uint32 minRoyaltyIpAccount2 = 100; // 10% + bytes memory data2 = abi.encode(minRoyaltyIpAccount2); + + royaltyModule.setRoyaltyPolicy(ipAccount2, address(royaltyPolicyLS), parentIpIds2, data2); + + (address splitClone1,,,) = royaltyPolicyLS.royaltyData(ipAccount1); + + vm.startPrank(ipAccount2); + IERC20(USDC).approve(address(royaltyPolicyLS), royaltyAmount); + + uint256 ipAccount2USDCBalBefore = IERC20(USDC).balanceOf(ipAccount2); + uint256 splitClone1USDCBalBefore = IERC20(USDC).balanceOf(splitClone1); + + vm.expectEmit(true, true, true, true, address(royaltyModule)); + emit RoyaltyPaid(ipAccount1, ipAccount2, ipAccount2, USDC, royaltyAmount); + + royaltyModule.payRoyaltyOnBehalf(ipAccount1, ipAccount2, USDC, royaltyAmount); + + uint256 ipAccount2USDCBalAfter = IERC20(USDC).balanceOf(ipAccount2); + uint256 splitClone1USDCBalAfter = IERC20(USDC).balanceOf(splitClone1); + + assertEq(ipAccount2USDCBalBefore - ipAccount2USDCBalAfter, royaltyAmount); + assertEq(splitClone1USDCBalAfter - splitClone1USDCBalBefore, royaltyAmount); + } +} \ No newline at end of file diff --git a/test/foundry/modules/royalty/RoyaltyPolicyLS.t.sol b/test/foundry/modules/royalty/RoyaltyPolicyLS.t.sol new file mode 100644 index 000000000..d72a9b5ef --- /dev/null +++ b/test/foundry/modules/royalty/RoyaltyPolicyLS.t.sol @@ -0,0 +1,210 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.23; + +import { console2 } from "forge-std/console2.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +import { TestHelper } from "../../../utils/TestHelper.sol"; +import { RoyaltyPolicyLS } from "../../../../contracts/modules/royalty-module/policies/RoyaltyPolicyLS.sol"; +import { IClaimerLS } from "../../../../contracts/interfaces/modules/royalty/policies/IClaimerLS.sol"; +import { ILiquidSplitClone } from "../../../../contracts/interfaces/modules/royalty/policies/ILiquidSplitClone.sol"; +import { Errors } from "../../../../contracts/lib/Errors.sol"; + + + +contract TestLSClaimer is TestHelper { + RoyaltyPolicyLS internal testRoyaltyPolicyLS; + + function setUp() public override { + super.setUp(); + + // whitelist royalty policy + royaltyModule.whitelistRoyaltyPolicy(address(royaltyPolicyLS), true); + } + + function test_RoyaltyPolicyLS_constructor_revert_ZeroRoyaltyModule() public { + vm.expectRevert(Errors.RoyaltyPolicyLS__ZeroRoyaltyModule.selector); + + testRoyaltyPolicyLS = new RoyaltyPolicyLS(address(0), address(1), LIQUID_SPLIT_FACTORY, LIQUID_SPLIT_MAIN); + } + + function test_RoyaltyPolicyLS_constructor_revert_ZeroLicenseRegistry() public { + vm.expectRevert(Errors.RoyaltyPolicyLS__ZeroLicenseRegistry.selector); + + testRoyaltyPolicyLS = new RoyaltyPolicyLS(address(royaltyModule), address(0), LIQUID_SPLIT_FACTORY, LIQUID_SPLIT_MAIN); + } + + function test_RoyaltyPolicyLS_constructor_revert_ZeroLiquidSplitFactory() public { + vm.expectRevert(Errors.RoyaltyPolicyLS__ZeroLiquidSplitFactory.selector); + + testRoyaltyPolicyLS = new RoyaltyPolicyLS(address(royaltyModule), address(1), address(0), LIQUID_SPLIT_MAIN); + } + + function test_RoyaltyPolicyLS_constructor_revert_ZeroLiquidSplitMain() public { + vm.expectRevert(Errors.RoyaltyPolicyLS__ZeroLiquidSplitMain.selector); + + testRoyaltyPolicyLS = new RoyaltyPolicyLS(address(royaltyModule), address(1), LIQUID_SPLIT_FACTORY, address(0)); + } + + function test_RoyaltyPolicyLS_constructor() public { + testRoyaltyPolicyLS = new RoyaltyPolicyLS(address(royaltyModule), address(1), LIQUID_SPLIT_FACTORY, LIQUID_SPLIT_MAIN); + + assertEq(testRoyaltyPolicyLS.ROYALTY_MODULE(), address(royaltyModule)); + assertEq(testRoyaltyPolicyLS.LICENSE_REGISTRY(), address(1)); + assertEq(testRoyaltyPolicyLS.LIQUID_SPLIT_FACTORY(), LIQUID_SPLIT_FACTORY); + assertEq(testRoyaltyPolicyLS.LIQUID_SPLIT_MAIN(), LIQUID_SPLIT_MAIN); + + } + + function test_RoyaltyPolicyLS_revert_InvalidMinRoyalty() public { + // set root parent royalty policy + address[] memory parentIpIds1 = new address[](0); + uint32 minRoyaltyIpAccount1 = 100; + bytes memory data1 = abi.encode(minRoyaltyIpAccount1); + royaltyModule.setRoyaltyPolicy(ipAccount1, address(royaltyPolicyLS), parentIpIds1, data1); + + // set derivative royalty policy + address[] memory parentIpIds2 = new address[](1); + parentIpIds2[0] = ipAccount1; + uint32 minRoyaltyIpAccount2 = 5; + bytes memory data2 = abi.encode(minRoyaltyIpAccount2); + + vm.expectRevert(Errors.RoyaltyPolicyLS__InvalidMinRoyalty.selector); + royaltyModule.setRoyaltyPolicy(ipAccount2, address(royaltyPolicyLS), parentIpIds2, data2); + } + + function test_RoyaltyPolicyLS_revert_ZeroMinRoyalty() public { + // set root parent royalty policy + address[] memory parentIpIds1 = new address[](0); + uint32 minRoyaltyIpAccount1 = 100; + bytes memory data1 = abi.encode(minRoyaltyIpAccount1); + royaltyModule.setRoyaltyPolicy(ipAccount1, address(royaltyPolicyLS), parentIpIds1, data1); + + // set derivative royalty policy + address[] memory parentIpIds2 = new address[](1); + parentIpIds2[0] = ipAccount1; + uint32 minRoyaltyIpAccount2 = 0; + bytes memory data2 = abi.encode(minRoyaltyIpAccount2); + + vm.expectRevert(Errors.RoyaltyPolicyLS__ZeroMinRoyalty.selector); + royaltyModule.setRoyaltyPolicy(ipAccount2, address(royaltyPolicyLS), parentIpIds2, data2); + } + + function test_RoyaltyPolicyLS_revert_InvalidRoyaltyStack() public { + address[] memory parentIpIds = new address[](0); + uint32 minRoyaltyIpAccount3 = 1010; // 100.1% + bytes memory data = abi.encode(minRoyaltyIpAccount3); + + vm.expectRevert(Errors.RoyaltyPolicyLS__InvalidRoyaltyStack.selector); + royaltyModule.setRoyaltyPolicy(ipAccount3, address(royaltyPolicyLS), parentIpIds, data); + } + + function test_RoyaltyPolicyLS_initPolicy_rootIPA() public { + address[] memory parentIpIds = new address[](0); + uint32 minRoyaltyIpAccount1 = 0; + bytes memory data = abi.encode(minRoyaltyIpAccount1); + + royaltyModule.setRoyaltyPolicy(ipAccount1, address(royaltyPolicyLS), parentIpIds, data); + + (address splitClone, address claimer, uint32 royaltyStack, uint32 minRoyalty) = royaltyPolicyLS.royaltyData(ipAccount1); + + assertFalse(splitClone == address(0)); + assertEq(claimer, address(royaltyPolicyLS)); + assertEq(royaltyStack, minRoyaltyIpAccount1); + assertEq(minRoyalty, minRoyaltyIpAccount1); + } + + function test_RoyaltyPolicyLS_initPolicy_derivativeIPA() public { + // set root parent royalty policy + address[] memory parentIpIds1 = new address[](0); + uint32 minRoyaltyIpAccount1 = 100; + bytes memory data1 = abi.encode(minRoyaltyIpAccount1); + royaltyModule.setRoyaltyPolicy(ipAccount1, address(royaltyPolicyLS), parentIpIds1, data1); + + // set derivative royalty policy + address[] memory parentIpIds2 = new address[](1); + parentIpIds2[0] = ipAccount1; + uint32 minRoyaltyIpAccount2 = 200; + bytes memory data2 = abi.encode(minRoyaltyIpAccount2); + royaltyModule.setRoyaltyPolicy(ipAccount2, address(royaltyPolicyLS), parentIpIds2, data2); + + (address splitClone, address claimer, uint32 royaltyStack, uint32 minRoyalty) = royaltyPolicyLS.royaltyData(ipAccount2); + + assertFalse(splitClone == address(0)); + assertFalse(claimer == address(royaltyPolicyLS)); + assertFalse(claimer == address(0)); + assertEq(royaltyStack, minRoyaltyIpAccount1 + minRoyaltyIpAccount2); + assertEq(minRoyalty, minRoyaltyIpAccount2); + } + + function test_RoyaltyPolicyLS_distributeFunds() public { + // set root parent royalty policy + address[] memory parentIpIds1 = new address[](0); + uint32 minRoyaltyIpAccount1 = 100; + bytes memory data1 = abi.encode(minRoyaltyIpAccount1); + royaltyModule.setRoyaltyPolicy(ipAccount1, address(royaltyPolicyLS), parentIpIds1, data1); + + // set derivative royalty policy + address[] memory parentIpIds2 = new address[](1); + parentIpIds2[0] = ipAccount1; + uint32 minRoyaltyIpAccount2 = 200; + bytes memory data2 = abi.encode(minRoyaltyIpAccount2); + royaltyModule.setRoyaltyPolicy(ipAccount2, address(royaltyPolicyLS), parentIpIds2, data2); + (address splitClone2, address claimer2,,) = royaltyPolicyLS.royaltyData(ipAccount2); + + // send USDC to 0xSplitClone + vm.startPrank(USDC_RICH); + uint256 royaltyAmount = 1000 * 10 ** 6; + IERC20(USDC).transfer(splitClone2, royaltyAmount); + vm.stopPrank(); + + address[] memory accounts = new address[](2); + accounts[1] = ipAccount2; + accounts[0] = claimer2; + + uint256 splitClone2USDCBalBefore = IERC20(USDC).balanceOf(splitClone2); + uint256 splitMainUSDCBalBefore = IERC20(USDC).balanceOf(royaltyPolicyLS.LIQUID_SPLIT_MAIN()); + + royaltyPolicyLS.distributeFunds(ipAccount2, USDC, accounts, address(0)); + + uint256 splitClone2USDCBalAfter = IERC20(USDC).balanceOf(splitClone2); + uint256 splitMainUSDCBalAfter = IERC20(USDC).balanceOf(royaltyPolicyLS.LIQUID_SPLIT_MAIN()); + + assertApproxEqRel(splitClone2USDCBalBefore - splitClone2USDCBalAfter, royaltyAmount, 0.0001e18); + assertApproxEqRel(splitMainUSDCBalAfter - splitMainUSDCBalBefore, royaltyAmount, 0.0001e18); + } + + function test_RoyaltyPolicyLS_claimRoyalties() public{ + // set root parent royalty policy + address[] memory parentIpIds1 = new address[](0); + uint32 minRoyaltyIpAccount1 = 100; + bytes memory data1 = abi.encode(minRoyaltyIpAccount1); + royaltyModule.setRoyaltyPolicy(ipAccount1, address(royaltyPolicyLS), parentIpIds1, data1); + + // set derivative royalty policy + address[] memory parentIpIds2 = new address[](1); + parentIpIds2[0] = ipAccount1; + uint32 minRoyaltyIpAccount2 = 200; + bytes memory data2 = abi.encode(minRoyaltyIpAccount2); + royaltyModule.setRoyaltyPolicy(ipAccount2, address(royaltyPolicyLS), parentIpIds2, data2); + (address splitClone2, address claimer2,,) = royaltyPolicyLS.royaltyData(ipAccount2); + + // send USDC to 0xSplitClone + vm.startPrank(USDC_RICH); + uint256 royaltyAmount = 1000 * 10 ** 6; + IERC20(USDC).transfer(splitClone2, royaltyAmount); + vm.stopPrank(); + + address[] memory accounts = new address[](2); + accounts[1] = ipAccount2; + accounts[0] = claimer2; + + ERC20[] memory tokens = new ERC20[](1); + tokens[0] = ERC20(USDC); + + royaltyPolicyLS.distributeFunds(ipAccount2, USDC, accounts, address(0)); + + royaltyPolicyLS.claimRoyalties(ipAccount2, 0, tokens); + } +} \ No newline at end of file diff --git a/test/utils/DeployHelper.sol b/test/utils/DeployHelper.sol index 7f770b48e..79f4c68be 100644 --- a/test/utils/DeployHelper.sol +++ b/test/utils/DeployHelper.sol @@ -4,52 +4,208 @@ pragma solidity ^0.8.23; import { Test } from "forge-std/Test.sol"; import { console2 } from "forge-std/console2.sol"; +// external +import { IERC1155 } from "@openzeppelin/contracts/token/ERC1155/IERC1155.sol"; +import { Strings } from "@openzeppelin/contracts/utils/Strings.sol"; +import { Test } from "forge-std/Test.sol"; +import { ERC6551Registry } from "lib/reference/src/ERC6551Registry.sol"; +import { IERC6551Account } from "lib/reference/src/interfaces/IERC6551Account.sol"; +import { IERC6551Registry } from "lib/reference/src/interfaces/IERC6551Registry.sol"; +import { ERC6551AccountLib } from "lib/reference/src/lib/ERC6551AccountLib.sol"; + +// contracts +import { AccessController } from "contracts/AccessController.sol"; +import { Governance } from "contracts/governance/Governance.sol"; +import { IPAccountImpl } from "contracts/IPAccountImpl.sol"; +import { IIPAccount } from "contracts/interfaces/IIPAccount.sol"; +import { IRegistrationModule } from "contracts/interfaces/modules/IRegistrationModule.sol"; +import { IIPAccountRegistry } from "contracts/interfaces/registries/IIPAccountRegistry.sol"; +import { IIPAssetRegistry } from "contracts/interfaces/registries/IIPAssetRegistry.sol"; +import { ILicenseRegistry } from "contracts/interfaces/registries/ILicenseRegistry.sol"; +import { Errors } from "contracts/lib/Errors.sol"; +import { Licensing } from "contracts/lib/Licensing.sol"; +import { IP_RESOLVER_MODULE_KEY, REGISTRATION_MODULE_KEY } from "contracts/lib/modules/Module.sol"; +import { IPMetadataProvider } from "contracts/registries/metadata/IPMetadataProvider.sol"; +import { IPAccountRegistry } from "contracts/registries/IPAccountRegistry.sol"; +import { IPAssetRegistry } from "contracts/registries/IPAssetRegistry.sol"; +import { IPAssetRenderer } from "contracts/registries/metadata/IPAssetRenderer.sol"; +import { ModuleRegistry } from "contracts/registries/ModuleRegistry.sol"; +import { LicenseRegistry } from "contracts/registries/LicenseRegistry.sol"; +import { IPResolver } from "contracts/resolvers/IPResolver.sol"; +import { RegistrationModule } from "contracts/modules/RegistrationModule.sol"; +import { RoyaltyModule } from "contracts/modules/royalty-module/RoyaltyModule.sol"; +import { RoyaltyPolicyLS } from "contracts/modules/royalty-module/policies/RoyaltyPolicyLS.sol"; +import { TaggingModule } from "contracts/modules/tagging/TaggingModule.sol"; +import { DisputeModule } from "contracts/modules/dispute-module/DisputeModule.sol"; +import { ArbitrationPolicySP } from "contracts/modules/dispute-module/policies/ArbitrationPolicySP.sol"; + +// test +import { MockERC20 } from "test/foundry/mocks/MockERC20.sol"; +import { MockERC721 } from "test/foundry/mocks/MockERC721.sol"; +import { MockModule } from "test/foundry/mocks/MockModule.sol"; +import { Users, UsersLib } from "test/foundry/utils/Users.sol"; + import { DisputeModule } from "../../contracts/modules/dispute-module/DisputeModule.sol"; import { ArbitrationPolicySP } from "../../contracts/modules/dispute-module/policies/ArbitrationPolicySP.sol"; import { RoyaltyModule } from "../../contracts/modules/royalty-module/RoyaltyModule.sol"; import { RoyaltyPolicyLS } from "../../contracts/modules/royalty-module/policies/RoyaltyPolicyLS.sol"; +import { LSClaimer } from "../../contracts/modules/royalty-module/policies/LSClaimer.sol"; import { MockUSDC } from "../foundry/mocks/MockUSDC.sol"; +struct MockERC721s { + MockERC721 ape; + MockERC721 cat; + MockERC721 dog; +} + contract DeployHelper is Test { - DisputeModule public disputeModule; - ArbitrationPolicySP public arbitrationPolicySP; - RoyaltyModule public royaltyModule; - RoyaltyPolicyLS public royaltyPolicyLS; + ERC6551Registry internal erc6551Registry; + IPAccountImpl internal ipAccountImpl; + + // Registry + IPAccountRegistry internal ipAccountRegistry; + IPMetadataProvider internal ipMetadataProvider; + IPAssetRegistry internal ipAssetRegistry; + LicenseRegistry internal licenseRegistry; + ModuleRegistry internal moduleRegistry; + + // Modules + RegistrationModule internal registrationModule; + DisputeModule internal disputeModule; + ArbitrationPolicySP internal arbitrationPolicySP; + RoyaltyModule internal royaltyModule; + RoyaltyPolicyLS internal royaltyPolicyLS; + LSClaimer internal lsClaimer; + TaggingModule internal taggingModule; + + // Misc. + Governance internal governance; + AccessController internal accessController; + IPAssetRenderer internal ipAssetRenderer; + IPResolver internal ipResolver; - uint256 public constant ARBITRATION_PRICE = 1000 * 10 ** 6; // 1000 USDC + // Mocks + MockERC20 internal erc20; + MockERC721s internal erc721; + + // Helpers + Users internal u; + + uint256 internal constant ARBITRATION_PRICE = 1000 * 10 ** 6; // 1000 USDC // USDC - string public constant USDC_NAME = "USD Coin"; - string public constant USDC_SYMBOL = "USDC"; - uint8 public constant USDC_DECIMALS = 6; - address public constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; - address public constant USDC_RICH = 0xcEe284F754E854890e311e3280b767F80797180d; + string internal constant USDC_NAME = "USD Coin"; + string internal constant USDC_SYMBOL = "USDC"; + uint8 internal constant USDC_DECIMALS = 6; + address internal constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; + address internal constant USDC_RICH = 0xcEe284F754E854890e311e3280b767F80797180d; // Liquid Split (ETH Mainnet) - address public constant LIQUID_SPLIT_FACTORY = 0xdEcd8B99b7F763e16141450DAa5EA414B7994831; - address public constant LIQUID_SPLIT_MAIN = 0x2ed6c4B5dA6378c7897AC67Ba9e43102Feb694EE; + address internal constant LIQUID_SPLIT_FACTORY = 0xdEcd8B99b7F763e16141450DAa5EA414B7994831; + address internal constant LIQUID_SPLIT_MAIN = 0x2ed6c4B5dA6378c7897AC67Ba9e43102Feb694EE; + + function deploy() public virtual { + u = UsersLib.createMockUsers(vm); + + _deployContracts(); + _configDeployedContracts(); + _deployMockAssets(); + _mintMockAssets(); + _configAccessControl(); + } + + /*////////////////////////////////////////////////////////////////////////// + DEPLOYMENT LOGICS + //////////////////////////////////////////////////////////////////////////*/ + + function _deployContracts() internal { + governance = new Governance(u.admin); + + accessController = new AccessController(address(governance)); + erc6551Registry = new ERC6551Registry(); + ipAccountImpl = new IPAccountImpl(); - function deploy() public { + moduleRegistry = new ModuleRegistry(address(governance)); + ipAccountRegistry = new IPAccountRegistry( + address(erc6551Registry), + address(accessController), + address(ipAccountImpl) + ); + licenseRegistry = new LicenseRegistry(address(accessController), address(ipAccountRegistry)); + ipMetadataProvider = new IPMetadataProvider(address(moduleRegistry)); + ipAssetRegistry = new IPAssetRegistry( + address(accessController), + address(erc6551Registry), + address(ipAccountImpl), + address(ipMetadataProvider) + ); + ipResolver = new IPResolver(address(accessController), address(ipAssetRegistry), address(licenseRegistry)); + registrationModule = new RegistrationModule( + address(accessController), + address(ipAssetRegistry), + address(licenseRegistry) + ); + taggingModule = new TaggingModule(); + royaltyModule = new RoyaltyModule(); disputeModule = new DisputeModule(); + ipAssetRenderer = new IPAssetRenderer( + address(ipAssetRegistry), + address(licenseRegistry), + address(taggingModule), + address(royaltyModule) + ); + arbitrationPolicySP = new ArbitrationPolicySP(address(disputeModule), USDC, ARBITRATION_PRICE); - royaltyModule = new RoyaltyModule(); - royaltyPolicyLS = new RoyaltyPolicyLS(address(royaltyModule), LIQUID_SPLIT_FACTORY, LIQUID_SPLIT_MAIN); - - // if code at USDC is zero, then deploy ERC20 to that address - // and also deploy other RoyaltyModule dependencies - if (USDC.code.length == 0) { - bytes memory code = vm.getDeployedCode("MockUSDC.sol:MockUSDC"); - vm.etch(USDC, code); - vm.label(USDC, "USDC"); - // assertEq(USDC.code, code); - MockUSDC(USDC).mint(USDC_RICH, 100_000_000 ether); - } - - vm.label(address(disputeModule), "disputeModule"); - vm.label(address(arbitrationPolicySP), "arbitrationPolicySP"); - vm.label(address(royaltyModule), "royaltyModule"); - vm.label(address(royaltyPolicyLS), "royaltyPolicyLS"); - - // console2.logBytes(0xdEcd8B99b7F763e16141450DAa5EA414B7994831.code); + royaltyPolicyLS = new RoyaltyPolicyLS( + address(royaltyModule), + address(licenseRegistry), + LIQUID_SPLIT_FACTORY, + LIQUID_SPLIT_MAIN + ); + } + + function _configDeployedContracts() internal { + vm.startPrank(u.admin); + accessController.initialize(address(ipAccountRegistry), address(moduleRegistry)); + + moduleRegistry.registerModule(REGISTRATION_MODULE_KEY, address(registrationModule)); + moduleRegistry.registerModule(IP_RESOLVER_MODULE_KEY, address(ipResolver)); + moduleRegistry.registerModule("LICENSE_REGISTRY", address(licenseRegistry)); + + royaltyModule.whitelistRoyaltyPolicy(address(royaltyPolicyLS), true); + vm.stopPrank(); + } + + function _deployMockAssets() internal { + erc20 = new MockERC20(); + erc721 = MockERC721s({ ape: new MockERC721("Ape"), cat: new MockERC721("Cat"), dog: new MockERC721("Dog") }); + } + + function _mintMockAssets() internal { + erc20.mint(u.alice, 1000 * 10 ** erc20.decimals()); + erc20.mint(u.bob, 1000 * 10 ** erc20.decimals()); + erc20.mint(u.carl, 1000 * 10 ** erc20.decimals()); + // skip minting NFTs + } + + function _configAccessControl() internal { + // Set global perm to allow Registration Module to call License Registry on all IPAccounts + vm.startPrank(u.admin); // admin of governance + + accessController.setGlobalPermission( + address(registrationModule), + address(licenseRegistry), + bytes4(licenseRegistry.linkIpToParent.selector), + 1 // AccessPermission.ALLOW + ); + + accessController.setGlobalPermission( + address(registrationModule), + address(licenseRegistry), + bytes4(licenseRegistry.addPolicyToIp.selector), + 1 // AccessPermission.ALLOW + ); + + vm.stopPrank(); } } diff --git a/test/utils/TestHelper.sol b/test/utils/TestHelper.sol index ab338f8de..562e20318 100644 --- a/test/utils/TestHelper.sol +++ b/test/utils/TestHelper.sol @@ -1,9 +1,22 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.23; -import {console2} from "forge-std/console2.sol"; -import {Test} from "forge-std/Test.sol"; -import {DeployHelper} from "./DeployHelper.sol"; +// external +import { console2 } from "forge-std/console2.sol"; +import { Test } from "forge-std/Test.sol"; + +// contracts +import { UMLPolicyFrameworkManager, UMLPolicy } from "contracts/modules/licensing/UMLPolicyFrameworkManager.sol"; + +// test +import { UMLPolicyGenericParams, UMLPolicyCommercialParams, UMLPolicyDerivativeParams } from "test/foundry/integration/shared/LicenseHelper.sol"; +import { MockERC721 } from "test/foundry/mocks/MockERC721.sol"; +import { DeployHelper } from "test/utils/DeployHelper.sol"; + +struct PolicyFrameworkManagerData { + string name; + address addr; +} contract TestHelper is Test, DeployHelper { uint256 internal constant accountA = 1; @@ -15,16 +28,21 @@ contract TestHelper is Test, DeployHelper { uint256 internal constant accountG = 7; address internal deployer; - address internal governance; address internal arbitrationRelayer; address internal ipAccount1; address internal ipAccount2; address internal ipAccount3; address internal ipAccount4; + MockERC721 internal nft; + uint256[] internal nftIds; + + mapping(string policyFrameworkManagerName => PolicyFrameworkManagerData) internal pfms; + + mapping(string policyName => uint256 policyId) internal policyIds; + function setUp() public virtual { deployer = vm.addr(accountA); - governance = vm.addr(accountB); arbitrationRelayer = vm.addr(accountC); ipAccount1 = vm.addr(accountD); ipAccount2 = vm.addr(accountE); @@ -34,11 +52,57 @@ contract TestHelper is Test, DeployHelper { deploy(); vm.label(deployer, "deployer"); - vm.label(governance, "governance"); vm.label(arbitrationRelayer, "arbitrationRelayer"); vm.label(ipAccount1, "ipAccount1"); vm.label(ipAccount2, "ipAccount2"); vm.label(ipAccount3, "ipAccount3"); vm.label(ipAccount4, "ipAccount4"); } + + function _setUMLPolicyFrameworkManager() internal { + UMLPolicyFrameworkManager umlPfm = new UMLPolicyFrameworkManager( + address(accessController), + address(licenseRegistry), + "UML_MINT_PAYMENT", + "license Url" + ); + licenseRegistry.registerPolicyFrameworkManager(address(umlPfm)); + + pfms["uml"] = PolicyFrameworkManagerData({ name: "uml", addr: address(umlPfm) }); + } + + function _addUMLPolicy( + bool commercialUse, + bool derivativesAllowed, + UMLPolicyGenericParams memory gparams, + UMLPolicyCommercialParams memory cparams, + UMLPolicyDerivativeParams memory dparams + ) internal { + string memory pName = string(abi.encodePacked("uml_", gparams.policyName)); + policyIds[pName] = UMLPolicyFrameworkManager(pfms["uml"].addr).registerPolicy( + UMLPolicy({ + attribution: gparams.attribution, + transferable: gparams.transferable, + commercialUse: commercialUse, + commercialAttribution: cparams.commercialAttribution, + commercializers: cparams.commercializers, + commercialRevShare: cparams.commercialRevShare, + derivativesAllowed: derivativesAllowed, + derivativesAttribution: dparams.derivativesAttribution, + derivativesApproval: dparams.derivativesApproval, + derivativesReciprocal: dparams.derivativesReciprocal, + derivativesRevShare: dparams.derivativesRevShare, + territories: gparams.territories, + distributionChannels: gparams.distributionChannels + }) + ); + } + + function _getIpId(MockERC721 mnft, uint256 tokenId) internal view returns (address ipId) { + return _getIpId(address(mnft), tokenId); + } + + function _getIpId(address mnft, uint256 tokenId) internal view returns (address ipId) { + return ipAccountRegistry.ipAccount(block.chainid, mnft, tokenId); + } }