diff --git a/src/story_protocol_python_sdk/__init__.py b/src/story_protocol_python_sdk/__init__.py index 062f1c3..6e2d56e 100644 --- a/src/story_protocol_python_sdk/__init__.py +++ b/src/story_protocol_python_sdk/__init__.py @@ -14,10 +14,15 @@ CollectRoyaltiesResponse, ) from .types.resource.IPAsset import ( + LicenseTermsDataInput, + RegisterAndAttachAndDistributeRoyaltyTokensResponse, RegisterPILTermsAndAttachResponse, RegistrationResponse, + RegistrationWithRoyaltyVaultAndLicenseTermsResponse, RegistrationWithRoyaltyVaultResponse, ) +from .types.resource.License import LicenseTermsInput +from .types.resource.Royalty import RoyaltyShareInput from .utils.constants import ( DEFAULT_FUNCTION_SELECTOR, MAX_ROYALTY_TOKEN, @@ -30,7 +35,6 @@ from .utils.derivative_data import DerivativeDataInput from .utils.ip_metadata import IPMetadataInput from .utils.licensing_config_data import LicensingConfig -from .utils.royalty_shares import RoyaltyShareInput __all__ = [ "StoryClient", @@ -46,12 +50,16 @@ "IPMetadataInput", "RegistrationResponse", "RegistrationWithRoyaltyVaultResponse", + "RegistrationWithRoyaltyVaultAndLicenseTermsResponse", + "RegisterAndAttachAndDistributeRoyaltyTokensResponse", + "LicenseTermsDataInput", "ClaimRewardsResponse", "ClaimReward", "CollectRoyaltiesResponse", "LicensingConfig", "RegisterPILTermsAndAttachResponse", "RoyaltyShareInput", + "LicenseTermsInput", # Constants "ZERO_ADDRESS", "ZERO_HASH", diff --git a/src/story_protocol_python_sdk/resources/IPAsset.py b/src/story_protocol_python_sdk/resources/IPAsset.py index bbd9509..d191cb9 100644 --- a/src/story_protocol_python_sdk/resources/IPAsset.py +++ b/src/story_protocol_python_sdk/resources/IPAsset.py @@ -1,5 +1,7 @@ """Module for handling IP Account operations and transactions.""" +from dataclasses import asdict, is_dataclass + from ens.ens import Address, HexStr from web3 import Web3 @@ -18,6 +20,9 @@ from story_protocol_python_sdk.abi.IPAssetRegistry.IPAssetRegistry_client import ( IPAssetRegistryClient, ) +from story_protocol_python_sdk.abi.IpRoyaltyVaultImpl.IpRoyaltyVaultImpl_client import ( + IpRoyaltyVaultImplClient, +) from story_protocol_python_sdk.abi.LicenseAttachmentWorkflows.LicenseAttachmentWorkflows_client import ( LicenseAttachmentWorkflowsClient, ) @@ -45,10 +50,14 @@ from story_protocol_python_sdk.abi.SPGNFTImpl.SPGNFTImpl_client import SPGNFTImplClient from story_protocol_python_sdk.types.common import AccessPermission from story_protocol_python_sdk.types.resource.IPAsset import ( + LicenseTermsDataInput, + RegisterAndAttachAndDistributeRoyaltyTokensResponse, RegisterPILTermsAndAttachResponse, RegistrationResponse, + RegistrationWithRoyaltyVaultAndLicenseTermsResponse, RegistrationWithRoyaltyVaultResponse, ) +from story_protocol_python_sdk.types.resource.Royalty import RoyaltyShareInput from story_protocol_python_sdk.utils.constants import ( MAX_ROYALTY_TOKEN, ZERO_ADDRESS, @@ -61,10 +70,7 @@ from story_protocol_python_sdk.utils.function_signature import get_function_signature from story_protocol_python_sdk.utils.ip_metadata import IPMetadata, IPMetadataInput from story_protocol_python_sdk.utils.license_terms import LicenseTerms -from story_protocol_python_sdk.utils.royalty_shares import ( - RoyaltyShare, - RoyaltyShareInput, -) +from story_protocol_python_sdk.utils.royalty import get_royalty_shares from story_protocol_python_sdk.utils.sign import Sign from story_protocol_python_sdk.utils.transaction_utils import build_and_send_transaction from story_protocol_python_sdk.utils.validation import ( @@ -433,18 +439,7 @@ def mint_and_register_ip_asset_with_pil_terms( raise ValueError( f"The NFT contract address {spg_nft_contract} is not valid." ) - license_terms = [] - for term in terms: - license_terms.append( - { - "terms": self.license_terms_util.validate_license_terms( - term["terms"] - ), - "licensingConfig": self.license_terms_util.validate_licensing_config( - term["licensing_config"] - ), - } - ) + license_terms = self._validate_license_terms_data(terms) metadata = { "ipMetadataURI": "", @@ -620,18 +615,7 @@ def register_ip_and_attach_pil_terms( raise ValueError( f"The NFT with id {token_id} is already registered as IP." ) - license_terms = [] - for term in license_terms_data: - license_terms.append( - { - "terms": self.license_terms_util.validate_license_terms( - term["terms"] - ), - "licensingConfig": self.license_terms_util.validate_licensing_config( - term["licensing_config"] - ), - } - ) + license_terms = self._validate_license_terms_data(license_terms_data) calculated_deadline = self.sign_util.get_deadline(deadline=deadline) @@ -995,6 +979,68 @@ def register_ip_and_make_derivative_with_license_tokens( f"Failed to register IP and make derivative with license tokens: {str(e)}" ) from e + def mint_and_register_ip_and_attach_pil_terms_and_distribute_royalty_tokens( + self, + spg_nft_contract: Address, + license_terms_data: list[LicenseTermsDataInput], + royalty_shares: list[RoyaltyShareInput], + ip_metadata: IPMetadataInput | None = None, + recipient: Address | None = None, + allow_duplicates: bool = True, + tx_options: dict | None = None, + ) -> RegistrationWithRoyaltyVaultAndLicenseTermsResponse: + """ + Mint an NFT and register the IP, attach PIL terms, and distribute royalty tokens. + + :param spg_nft_contract Address: The address of the SPGNFT collection. + :param license_terms_data `list[LicenseTermsDataInput]`: The PIL terms and licensing configuration data to be attached to the IP. + :param royalty_shares `list[RoyaltyShareInput]`: The royalty shares to be distributed. + :param ip_metadata `IPMetadataInput`: [Optional] The desired metadata for the newly minted NFT and newly registered IP. + :param recipient Address: [Optional] The address to receive the minted NFT. If not provided, the client's own wallet address will be used. + :param allow_duplicates bool: [Optional] Set to false to prevent minting an NFT with a duplicate metadata hash. (default: True) + :param tx_options dict: [Optional] Transaction options. + :return `RegistrationWithRoyaltyVaultAndLicenseTermsResponse`: Response with tx hash, IP ID, token ID, license terms IDs, and royalty vault address. + """ + try: + validated_royalty_shares = get_royalty_shares(royalty_shares)[ + "royalty_shares" + ] + license_terms = self._validate_license_terms_data(license_terms_data) + + response = build_and_send_transaction( + self.web3, + self.account, + self.royalty_token_distribution_workflows_client.build_mintAndRegisterIpAndAttachPILTermsAndDistributeRoyaltyTokens_transaction, + validate_address(spg_nft_contract), + self._validate_recipient(recipient), + IPMetadata.from_input(ip_metadata).get_validated_data(), + license_terms, + validated_royalty_shares, + allow_duplicates, + tx_options=tx_options, + ) + + ip_registered = self._parse_tx_ip_registered_event(response["tx_receipt"]) + license_terms_ids = self._parse_tx_license_terms_attached_event( + response["tx_receipt"] + ) + royalty_vault = self.get_royalty_vault_address_by_ip_id( + response["tx_receipt"], + ip_registered["ip_id"], + ) + + return RegistrationWithRoyaltyVaultAndLicenseTermsResponse( + tx_hash=response["tx_hash"], + ip_id=ip_registered["ip_id"], + token_id=ip_registered["token_id"], + license_terms_ids=license_terms_ids, + royalty_vault=royalty_vault, + ) + except Exception as e: + raise ValueError( + f"Failed to mint, register IP, attach PIL terms and distribute royalty tokens: {str(e)}" + ) from e + def mint_and_register_ip_and_make_derivative_and_distribute_royalty_tokens( self, spg_nft_contract: Address, @@ -1018,9 +1064,7 @@ def mint_and_register_ip_and_make_derivative_and_distribute_royalty_tokens( :return `RegistrationWithRoyaltyVaultResponse`: Dictionary with the tx hash, IP ID and token ID, royalty vault. """ try: - validated_royalty_shares_obj = RoyaltyShare.get_royalty_shares( - royalty_shares - ) + validated_royalty_shares_obj = get_royalty_shares(royalty_shares) validated_deriv_data = DerivativeData.from_input( web3=self.web3, input_data=deriv_data ).get_validated_data() @@ -1055,6 +1099,120 @@ def mint_and_register_ip_and_make_derivative_and_distribute_royalty_tokens( f"Failed to mint, register IP, make derivative and distribute royalty tokens: {str(e)}" ) from e + def register_ip_and_attach_pil_terms_and_distribute_royalty_tokens( + self, + nft_contract: Address, + token_id: int, + license_terms_data: list[LicenseTermsDataInput], + royalty_shares: list[RoyaltyShareInput], + ip_metadata: IPMetadataInput | None = None, + deadline: int | None = None, + tx_options: dict | None = None, + ) -> RegisterAndAttachAndDistributeRoyaltyTokensResponse: + """ + Register the given NFT and attach license terms and distribute royalty + tokens. In order to successfully distribute royalty tokens, the first license terms + attached to the IP must be a commercial license. + + :param nft_contract Address: The address of the NFT collection. + :param token_id int: The ID of the NFT. + :param license_terms_data `list[LicenseTermsDataInput]`: The data of the license and its configuration to be attached to the new group IP. + :param royalty_shares `list[RoyaltyShareInput]`: Authors of the IP and their shares of the royalty tokens. + :param ip_metadata `IPMetadataInput`: [Optional] The metadata for the newly registered IP. + :param deadline int: [Optional] The deadline for the signature in seconds. (default: 1000 seconds) + :param tx_options dict: [Optional] Transaction options. + :return `RegisterAndAttachAndDistributeRoyaltyTokensResponse`: Response with tx hash, license terms IDs, royalty vault address, and distribute royalty tokens transaction hash. + """ + try: + nft_contract = validate_address(nft_contract) + ip_id = self._get_ip_id(nft_contract, token_id) + if self._is_registered(ip_id): + raise ValueError( + f"The NFT with id {token_id} is already registered as IP." + ) + + license_terms = self._validate_license_terms_data(license_terms_data) + calculated_deadline = self.sign_util.get_deadline(deadline=deadline) + royalty_shares_obj = get_royalty_shares(royalty_shares) + + signature_response = self.sign_util.get_permission_signature( + ip_id=ip_id, + deadline=calculated_deadline, + state=self.web3.to_bytes(hexstr=HexStr(ZERO_HASH)), + permissions=[ + { + "ipId": ip_id, + "signer": self.royalty_token_distribution_workflows_client.contract.address, + "to": self.core_metadata_module_client.contract.address, + "permission": AccessPermission.ALLOW, + "func": "setAll(address,string,bytes32,bytes32)", + }, + { + "ipId": ip_id, + "signer": self.royalty_token_distribution_workflows_client.contract.address, + "to": self.licensing_module_client.contract.address, + "permission": AccessPermission.ALLOW, + "func": "attachLicenseTerms(address,address,uint256)", + }, + { + "ipId": ip_id, + "signer": self.royalty_token_distribution_workflows_client.contract.address, + "to": self.licensing_module_client.contract.address, + "permission": AccessPermission.ALLOW, + "func": "setLicensingConfig(address,address,uint256,(bool,uint256,address,bytes,uint32,bool,uint32,address))", + }, + ], + ) + + response = build_and_send_transaction( + self.web3, + self.account, + self.royalty_token_distribution_workflows_client.build_registerIpAndAttachPILTermsAndDeployRoyaltyVault_transaction, + nft_contract, + token_id, + IPMetadata.from_input(ip_metadata).get_validated_data(), + license_terms, + { + "signer": self.web3.to_checksum_address(self.account.address), + "deadline": calculated_deadline, + "signature": self.web3.to_bytes( + hexstr=signature_response["signature"] + ), + }, + tx_options=tx_options, + ) + ip_registered = self._parse_tx_ip_registered_event(response["tx_receipt"]) + license_terms_ids = self._parse_tx_license_terms_attached_event( + response["tx_receipt"] + ) + royalty_vault = self.get_royalty_vault_address_by_ip_id( + response["tx_receipt"], + ip_registered["ip_id"], + ) + + # Distribute royalty tokens + distribute_tx_hash = self._distribute_royalty_tokens( + ip_id=ip_registered["ip_id"], + royalty_shares=royalty_shares_obj["royalty_shares"], + royalty_vault=royalty_vault, + total_amount=royalty_shares_obj["total_amount"], + tx_options=tx_options, + deadline=calculated_deadline, + ) + + return RegisterAndAttachAndDistributeRoyaltyTokensResponse( + tx_hash=response["tx_hash"], + license_terms_ids=license_terms_ids, + royalty_vault=royalty_vault, + distribute_royalty_tokens_tx_hash=distribute_tx_hash, + ip_id=ip_registered["ip_id"], + token_id=ip_registered["token_id"], + ) + except Exception as e: + raise ValueError( + f"Failed to register IP, attach PIL terms and distribute royalty tokens: {str(e)}" + ) from e + def register_pil_terms_and_attach( self, ip_id: Address, @@ -1077,18 +1235,7 @@ def register_pil_terms_and_attach( calculated_deadline = self.sign_util.get_deadline(deadline=deadline) ip_account_impl_client = IPAccountImplClient(self.web3, ip_id) state = ip_account_impl_client.state() - license_terms = [] - for term in license_terms_data: - license_terms.append( - { - "terms": self.license_terms_util.validate_license_terms( - term["terms"] - ), - "licensingConfig": self.license_terms_util.validate_licensing_config( - term["licensing_config"] - ), - } - ) + license_terms = self._validate_license_terms_data(license_terms_data) signature_response = self.sign_util.get_permission_signature( ip_id=ip_id, deadline=calculated_deadline, @@ -1232,6 +1379,70 @@ def _validate_license_token_ids(self, license_token_ids: list) -> list: return license_token_ids + def _distribute_royalty_tokens( + self, + ip_id: Address, + royalty_shares: list[RoyaltyShareInput], + deadline: int, + royalty_vault: Address, + total_amount: int, + tx_options: dict | None = None, + ) -> HexStr: + """ + Distribute royalty tokens to specified recipients. + + This is an internal method that handles the distribution of royalty tokens + from an IP's royalty vault to the specified recipients. + + :param ip_id Address: The IP ID. + :param royalty_shares list[RoyaltyShareInput]: The validated royalty shares with recipient and percentage. + :param deadline int: The deadline for the signature. + :param royalty_vault Address: The address of the royalty vault. + :param total_amount int: The total amount of royalty tokens to distribute. + :param tx_options dict: [Optional] Transaction options. + :return HexStr: The transaction hash. + """ + try: + ip_account_impl_client = IPAccountImplClient(self.web3, ip_id) + state = ip_account_impl_client.state() + + ip_royalty_vault_client = IpRoyaltyVaultImplClient(self.web3, royalty_vault) + + signature_response = self.sign_util.get_signature( + state=state, + to=royalty_vault, + encode_data=ip_royalty_vault_client.contract.encode_abi( + abi_element_identifier="approve", + args=[ + self.royalty_token_distribution_workflows_client.contract.address, + total_amount, + ], + ), + verifying_contract=ip_id, + deadline=deadline, + ) + + response = build_and_send_transaction( + self.web3, + self.account, + self.royalty_token_distribution_workflows_client.build_distributeRoyaltyTokens_transaction, + ip_id, + royalty_shares, + { + "signer": self.web3.to_checksum_address(self.account.address), + "deadline": deadline, + "signature": self.web3.to_bytes( + hexstr=signature_response["signature"] + ), + }, + tx_options=tx_options, + ) + + return response["tx_hash"] + + except Exception as e: + raise ValueError(f"Failed to distribute royalty tokens: {str(e)}") from e + def _get_ip_id(self, token_contract: str, token_id: int) -> str: """ Get the IP ID for a given token. @@ -1345,3 +1556,32 @@ def _validate_recipient(self, recipient: Address | None) -> Address: if recipient is None: return self.account.address return validate_address(recipient) + + def _validate_license_terms_data( + self, license_terms_data: list[LicenseTermsDataInput] | list[dict] + ) -> list: + """ + Validate the license terms data. + + :param license_terms_data `list[LicenseTermsDataInput]` or `list[dict]`: The license terms data to validate. + :return list: The validated license terms data. + """ + + validated_license_terms_data = [] + for term in license_terms_data: + if is_dataclass(term): + terms_dict = asdict(term.terms) + licensing_config_dict = term.licensing_config + else: + terms_dict = term["terms"] + licensing_config_dict = term["licensing_config"] + + validated_license_terms_data.append( + { + "terms": self.license_terms_util.validate_license_terms(terms_dict), + "licensingConfig": self.license_terms_util.validate_licensing_config( + licensing_config_dict + ), + } + ) + return validated_license_terms_data diff --git a/src/story_protocol_python_sdk/types/resource/IPAsset.py b/src/story_protocol_python_sdk/types/resource/IPAsset.py index 5e0f18a..3e7c21a 100644 --- a/src/story_protocol_python_sdk/types/resource/IPAsset.py +++ b/src/story_protocol_python_sdk/types/resource/IPAsset.py @@ -1,7 +1,11 @@ +from dataclasses import dataclass from typing import TypedDict from ens.ens import Address, HexStr +from story_protocol_python_sdk.types.resource.License import LicenseTermsInput +from story_protocol_python_sdk.utils.licensing_config_data import LicensingConfig + class RegistrationResponse(TypedDict): """ @@ -31,6 +35,21 @@ class RegistrationWithRoyaltyVaultResponse(RegistrationResponse): royalty_vault: Address +class RegistrationWithRoyaltyVaultAndLicenseTermsResponse( + RegistrationWithRoyaltyVaultResponse +): + """ + Response structure for IP asset registration operations with royalty vault and license terms. + + Extends `RegistrationWithRoyaltyVaultResponse` with license terms information. + + Attributes: + license_terms_ids: The IDs of the license terms attached to the IP asset. + """ + + license_terms_ids: list[int] + + class RegisterPILTermsAndAttachResponse(TypedDict): """ Response structure for Programmable IP License Terms registration and attachment operations. @@ -42,3 +61,32 @@ class RegisterPILTermsAndAttachResponse(TypedDict): tx_hash: HexStr license_terms_ids: list[int] + + +class RegisterAndAttachAndDistributeRoyaltyTokensResponse( + RegistrationWithRoyaltyVaultAndLicenseTermsResponse +): + """ + Response structure for IP asset registration operations with royalty vault, license terms and distribute royalty tokens. + + Extends `RegistrationWithRoyaltyVaultAndLicenseTermsResponse` with distribute royalty tokens transaction hash. + + Attributes: + distribute_royalty_tokens_tx_hash: The transaction hash of the distribute royalty tokens transaction. + """ + + distribute_royalty_tokens_tx_hash: HexStr + + +@dataclass +class LicenseTermsDataInput: + """ + Data structure for license terms. + + Attributes: + terms: The terms of the license. + licensing_config: The licensing configuration of the license. + """ + + terms: LicenseTermsInput + licensing_config: LicensingConfig diff --git a/src/story_protocol_python_sdk/types/resource/License.py b/src/story_protocol_python_sdk/types/resource/License.py new file mode 100644 index 0000000..8de050f --- /dev/null +++ b/src/story_protocol_python_sdk/types/resource/License.py @@ -0,0 +1,52 @@ +from dataclasses import dataclass + +from ens.ens import Address, HexStr + +from story_protocol_python_sdk.types.resource.Royalty import RoyaltyPolicyInput + + +@dataclass +class LicenseTermsInput: + """ + This structure defines the terms for a Programmable IP License (PIL). + These terms can be attached to IP Assets. + + For more information, see https://docs.story.foundation/concepts/programmable-ip-license/pil-terms + + Attributes: + transferable: Indicates whether the license is transferable or not. + royalty_policy: The address of the royalty policy contract which required to StoryProtocol in advance. + default_minting_fee: The default minting fee to be paid when minting a license. + expiration: The expiration period of the license. + commercial_use: Indicates whether the work can be used commercially or not. + commercial_attribution: Whether attribution is required when reproducing the work commercially or not. + commercializer_checker: Commercializers that are allowed to commercially exploit the work. If zero address, then no restrictions is enforced. + commercializer_checker_data: The data to be passed to the commercializer checker contract. + commercial_rev_share: Percentage of revenue that must be shared with the licensor. Must be between 0 and 100 (where 100% represents 100_000_000). + commercial_rev_ceiling: The maximum revenue that can be generated from the commercial use of the work. + derivatives_allowed: Indicates whether the licensee can create derivatives of his work or not. + derivatives_attribution: Indicates whether attribution is required for derivatives of the work or not. + derivatives_approval: Indicates whether the licensor must approve derivatives of the work before they can be linked to the licensor IP ID or not. + derivatives_reciprocal: Indicates whether the licensee must license derivatives of the work under the same terms or not. + derivative_rev_ceiling: The maximum revenue that can be generated from the derivative use of the work. + currency: The ERC20 token to be used to pay the minting fee. The token must be registered in story protocol. + uri: The URI of the license terms, which can be used to fetch the offchain license terms. + """ + + transferable: bool + royalty_policy: RoyaltyPolicyInput + default_minting_fee: int + expiration: int + commercial_use: bool + commercial_attribution: bool + commercializer_checker: Address + commercializer_checker_data: Address | HexStr + commercial_rev_share: int + commercial_rev_ceiling: int + derivatives_allowed: bool + derivatives_attribution: bool + derivatives_approval: bool + derivatives_reciprocal: bool + derivative_rev_ceiling: int + currency: Address + uri: str diff --git a/src/story_protocol_python_sdk/types/resource/Royalty.py b/src/story_protocol_python_sdk/types/resource/Royalty.py new file mode 100644 index 0000000..57740b2 --- /dev/null +++ b/src/story_protocol_python_sdk/types/resource/Royalty.py @@ -0,0 +1,40 @@ +from dataclasses import dataclass +from enum import IntEnum + +from ens.ens import Address + + +class NativeRoyaltyPolicy(IntEnum): + """ + Native royalty policy created by the Story team. + + For more information: + - LAP: https://docs.story.foundation/concepts/royalty-module/liquid-absolute-percentage + - LRP: https://docs.story.foundation/concepts/royalty-module/liquid-relative-percentage + + Attributes: + LAP: Liquid Absolute Percentage - defines that each parent IP Asset can choose a minimum royalty percentage that all of its downstream IP Assets in a derivative chain will share from their monetary gains as defined in the license agreement. + LRP: Liquid Relative Percentage - royalty policy defines that each parent IP Asset can choose a minimum royalty percentage that only the direct derivative IP Assets in a derivative chain will share from their monetary gains as defined in the license agreement. + """ + + LAP = 0 + LRP = 1 + + +# Type alias for royalty policy input +# Allow custom royalty policy address or use a native royalty policy enum. +# For custom royalty policy, see https://docs.story.foundation/concepts/royalty-module/external-royalty-policies +RoyaltyPolicyInput = Address | NativeRoyaltyPolicy + + +@dataclass +class RoyaltyShareInput: + """Input data structure for a single royalty share. + + Attributes: + recipient: The address of the recipient. + percentage: The percentage of the total royalty share. Supports up to 6 decimal places precision. For example, a value of 10 represents 10% of max royalty shares, which is 10,000,000. + """ + + recipient: Address + percentage: float | int diff --git a/src/story_protocol_python_sdk/utils/royalty.py b/src/story_protocol_python_sdk/utils/royalty.py new file mode 100644 index 0000000..367fca1 --- /dev/null +++ b/src/story_protocol_python_sdk/utils/royalty.py @@ -0,0 +1,93 @@ +"""Module for handling royalty-related utilities including shares and policy conversions.""" + +from typing import List + +from ens.ens import Address + +from story_protocol_python_sdk.types.resource.Royalty import ( + NativeRoyaltyPolicy, + RoyaltyShareInput, +) +from story_protocol_python_sdk.utils.constants import ( + ROYALTY_POLICY_LAP_ADDRESS, + ROYALTY_POLICY_LRP_ADDRESS, +) +from story_protocol_python_sdk.utils.validation import validate_address + + +def get_royalty_shares(royalty_shares: list[RoyaltyShareInput]) -> dict: + """ + Validate and convert royalty shares. + + :param royalty_shares: List of `RoyaltyShareInput`. + :return: Dictionary with validated royalty_shares and total_amount. + """ + if len(royalty_shares) == 0: + raise ValueError("Royalty shares must be provided.") + + actual_total = 0 + sum_percentage = 0.0 + converted_shares: List[dict] = [] + + for share_dict in royalty_shares: + recipient = validate_address(share_dict.recipient) + percentage = share_dict.percentage + + if percentage < 0: + raise ValueError( + "The percentage of the royalty shares must be greater than or equal to 0." + ) + + if percentage > 100: + raise ValueError( + "The percentage of the royalty shares must be less than or equal to 100." + ) + + sum_percentage += percentage + if sum_percentage > 100: + raise ValueError("The sum of the royalty shares cannot exceeds 100.") + + value = int(percentage * 10**6) + actual_total += value + + converted_shares.append( + { + "recipient": recipient, + "percentage": value, + } + ) + + return {"royalty_shares": converted_shares, "total_amount": actual_total} + + +def royalty_policy_input_to_address( + input: Address | NativeRoyaltyPolicy | None = None, +) -> Address: + """ + Convert RoyaltyPolicyInput to an address. + + Args: + input: The royalty policy input. Can be None, a NativeRoyaltyPolicy enum value, or a custom address. + + Returns: + Address: The corresponding royalty policy address. + - If None, returns the default LAP policy address + - If a string address, validates and returns it (custom address) + - If NativeRoyaltyPolicy.LAP (0), returns the LAP policy address + - If NativeRoyaltyPolicy.LRP (1), returns the LRP policy address + + Raises: + ValueError: If the custom address is invalid. + """ + if input is None: + return ROYALTY_POLICY_LAP_ADDRESS + + if isinstance(input, str): + return validate_address(input) + + if input == NativeRoyaltyPolicy.LAP: + return ROYALTY_POLICY_LAP_ADDRESS + elif input == NativeRoyaltyPolicy.LRP: + return ROYALTY_POLICY_LRP_ADDRESS + + return ROYALTY_POLICY_LAP_ADDRESS diff --git a/src/story_protocol_python_sdk/utils/royalty_shares.py b/src/story_protocol_python_sdk/utils/royalty_shares.py deleted file mode 100644 index b64de92..0000000 --- a/src/story_protocol_python_sdk/utils/royalty_shares.py +++ /dev/null @@ -1,71 +0,0 @@ -"""Module for handling royalty shares data structure and validation.""" - -from dataclasses import dataclass -from typing import List - -from ens.ens import Address - -from story_protocol_python_sdk.utils.validation import validate_address - - -@dataclass -class RoyaltyShareInput: - """Input data structure for a single royalty share. - - Attributes: - recipient: The address of the recipient. - percentage: The percentage of the total royalty share. Supports up to 6 decimal places precision. For example, a value of 10 represents 10% of max royalty shares, which is 10,000,000. - """ - - recipient: Address - percentage: float | int - - -@dataclass -class RoyaltyShare: - """Validated royalty share data.""" - - @classmethod - def get_royalty_shares(cls, royalty_shares: List[RoyaltyShareInput]): - """ - Validate and convert royalty shares. - - :param royalty_shares: List of `RoyaltyShareInput` - :return: Dictionary with validated royalty_shares and total_amount - """ - if len(royalty_shares) == 0: - raise ValueError("Royalty shares must be provided.") - - actual_total = 0 - sum_percentage = 0.0 - converted_shares: List[dict] = [] - - for share_dict in royalty_shares: - recipient = validate_address(share_dict.recipient) - percentage = share_dict.percentage - - if percentage < 0: - raise ValueError( - "The percentage of the royalty shares must be greater than or equal to 0." - ) - - if percentage > 100: - raise ValueError( - "The percentage of the royalty shares must be less than or equal to 100." - ) - - sum_percentage += percentage - if sum_percentage > 100: - raise ValueError("The sum of the royalty shares cannot exceeds 100.") - - value = int(percentage * 10**6) - actual_total += value - - converted_shares.append( - { - "recipient": recipient, - "percentage": value, - } - ) - - return {"royalty_shares": converted_shares, "total_amount": actual_total} diff --git a/tests/integration/test_integration_ip_asset.py b/tests/integration/test_integration_ip_asset.py index 152c168..6b241f7 100644 --- a/tests/integration/test_integration_ip_asset.py +++ b/tests/integration/test_integration_ip_asset.py @@ -1,16 +1,22 @@ import pytest +from story_protocol_python_sdk import ( + ZERO_ADDRESS, + ZERO_HASH, + DerivativeDataInput, + IPMetadataInput, + LicenseTermsDataInput, + LicenseTermsInput, + RoyaltyShareInput, + StoryClient, +) from story_protocol_python_sdk.abi.DerivativeWorkflows.DerivativeWorkflows_client import ( DerivativeWorkflowsClient, ) from story_protocol_python_sdk.abi.LicenseToken.LicenseToken_client import ( LicenseTokenClient, ) -from story_protocol_python_sdk.story_client import StoryClient -from story_protocol_python_sdk.utils.constants import ROYALTY_POLICY_LAP_ADDRESS -from story_protocol_python_sdk.utils.derivative_data import DerivativeDataInput -from story_protocol_python_sdk.utils.ip_metadata import IPMetadataInput -from story_protocol_python_sdk.utils.royalty_shares import RoyaltyShareInput +from story_protocol_python_sdk.utils.licensing_config_data import LicensingConfig from tests.integration.config.test_config import account_2 from tests.integration.config.utils import approve @@ -18,7 +24,6 @@ PIL_LICENSE_TEMPLATE, ROYALTY_POLICY, WIP_TOKEN_ADDRESS, - ZERO_ADDRESS, MockERC20, MockERC721, account, @@ -27,6 +32,14 @@ web3, ) +# Common test data for IP metadata +COMMON_IP_METADATA = IPMetadataInput( + ip_metadata_uri="https://example.com/metadata/custom-value.json", + ip_metadata_hash=web3.keccak(text="custom-value-metadata"), + nft_metadata_uri="https://example.com/metadata/custom-value-nft.json", + nft_metadata_hash=web3.keccak(text="custom-value-nft-metadata"), +) + class TestIPAssetRegistration: @pytest.fixture(scope="module") @@ -374,27 +387,77 @@ def test_mint_register_ip(self, story_client: StoryClient, nft_collection): spg_nft_contract=nft_collection, ip_metadata=metadata ) + def test_mint_and_register_ip_and_make_derivative( + self, story_client: StoryClient, nft_collection, parent_ip_and_license_terms + ): + """Test minting NFT, registering IP and making derivative with custom derivative data and metadata""" + response = story_client.IPAsset.mint_and_register_ip_and_make_derivative( + spg_nft_contract=nft_collection, + deriv_data=DerivativeDataInput( + parent_ip_ids=[parent_ip_and_license_terms["parent_ip_id"]], + license_terms_ids=[parent_ip_and_license_terms["license_terms_id"]], + max_minting_fee=10000, + max_rts=10, + max_revenue_share=100, + ), + ip_metadata=COMMON_IP_METADATA, + recipient=account_2.address, + allow_duplicates=False, + ) + assert response is not None + assert isinstance(response["tx_hash"], str) + assert isinstance(response["ip_id"], str) + assert isinstance(response["token_id"], int) -class TestSPGNFTOperations: - - # def test_register_ip_asset_with_metadata(self, story_client, nft_collection): - # token_id = mint_by_spg(nft_collection, story_client.web3, story_client.account, "test-metadata") + def test_mint_and_register_ip_and_make_derivative_with_license_tokens( + self, + story_client: StoryClient, + nft_collection, + mint_and_approve_license_token, + ): + """Test minting NFT, registering IP and making derivative using license tokens with custom metadata""" + license_token_ids = mint_and_approve_license_token + response = story_client.IPAsset.mint_and_register_ip_and_make_derivative_with_license_tokens( + spg_nft_contract=nft_collection, + license_token_ids=[license_token_ids[1]], + max_rts=100000000, + ip_metadata=COMMON_IP_METADATA, + recipient=account_2.address, + allow_duplicates=True, + ) + assert response is not None + assert isinstance(response["tx_hash"], str) + assert isinstance(response["ip_id"], str) + assert isinstance(response["token_id"], int) - # response = story_client.IPAsset.register( - # nft_contract=nft_collection, - # token_id=token_id, - # ip_metadata={ - # 'ip_metadata_uri': "test-uri", - # 'ip_metadata_hash': web3.to_hex(web3.keccak(text="test-metadata-hash")), - # 'nft_metadata_hash': web3.to_hex(web3.keccak(text="test-nft-metadata-hash")) - # }, - # deadline=1000 - # ) + def test_mint_and_register_ip_and_make_derivative_and_distribute_royalty_tokens( + self, story_client: StoryClient, nft_collection, parent_ip_and_license_terms + ): + """Test minting NFT, registering IP, making derivative and distributing royalty tokens with custom derivative data""" + response = story_client.IPAsset.mint_and_register_ip_and_make_derivative_and_distribute_royalty_tokens( + spg_nft_contract=nft_collection, + deriv_data=DerivativeDataInput( + parent_ip_ids=[parent_ip_and_license_terms["parent_ip_id"]], + license_terms_ids=[parent_ip_and_license_terms["license_terms_id"]], + max_minting_fee=10000, + max_rts=10, + max_revenue_share=100, + ), + royalty_shares=[ + RoyaltyShareInput(recipient=account.address, percentage=60), + RoyaltyShareInput(recipient=account_2.address, percentage=40), + ], + ip_metadata=COMMON_IP_METADATA, + recipient=account_2.address, + allow_duplicates=True, + ) + assert isinstance(response["tx_hash"], str) + assert isinstance(response["ip_id"], str) + assert isinstance(response["token_id"], int) + assert isinstance(response["royalty_vault"], str) - # assert 'ip_id' in response - # assert isinstance(response['ip_id'], str) - # assert response['ip_id'] != '' +class TestSPGNFTOperations: def test_register_derivative_ip( self, story_client: StoryClient, parent_ip_and_license_terms, nft_collection ): @@ -467,8 +530,11 @@ def test_register_derivative_ip( assert isinstance(result["ip_id"], str) and result["ip_id"] def test_register_ip_and_attach_pil_terms( - self, story_client: StoryClient, nft_collection, parent_ip_and_license_terms + self, + story_client: StoryClient, + nft_collection, ): + """Test registering IP and attaching multiple PIL terms with custom terms data""" token_id = mint_by_spg(nft_collection, story_client.web3, story_client.account) # Register IP and attach PIL terms @@ -549,8 +615,152 @@ def test_register_ip_and_attach_pil_terms( and result["license_terms_ids"] ) + def test_register_pil_terms_and_attach( + self, + story_client: StoryClient, + parent_ip_and_license_terms, + ): + """Test registering PIL terms and attaching them to an existing IP with multiple license terms""" + response = story_client.IPAsset.register_pil_terms_and_attach( + ip_id=parent_ip_and_license_terms["parent_ip_id"], + license_terms_data=[ + { + "terms": { + "transferable": True, + "royalty_policy": ROYALTY_POLICY, + "default_minting_fee": 1, + "expiration": 0, + "commercial_use": True, + "commercial_attribution": False, + "commercializer_checker": ZERO_ADDRESS, + "commercializer_checker_data": ZERO_ADDRESS, + "commercial_rev_share": 90, + "commercial_rev_ceiling": 0, + "derivatives_allowed": True, + "derivatives_attribution": True, + "derivatives_approval": False, + "derivatives_reciprocal": True, + "derivative_rev_ceiling": 0, + "currency": MockERC20, + "uri": "", + }, + "licensing_config": { + "is_set": True, + "minting_fee": 1, + "hook_data": "", + "licensing_hook": ZERO_ADDRESS, + "commercial_rev_share": 90, + "disabled": False, + "expect_minimum_group_reward_share": 0, + "expect_group_reward_pool": ZERO_ADDRESS, + }, + }, + { + "terms": { + "transferable": True, + "royalty_policy": ROYALTY_POLICY, + "default_minting_fee": 10, + "expiration": 0, + "commercial_use": True, + "commercial_attribution": False, + "commercializer_checker": ZERO_ADDRESS, + "commercializer_checker_data": ZERO_ADDRESS, + "commercial_rev_share": 10, + "commercial_rev_ceiling": 0, + "derivatives_allowed": True, + "derivatives_attribution": True, + "derivatives_approval": False, + "derivatives_reciprocal": True, + "derivative_rev_ceiling": 0, + "currency": MockERC20, + "uri": "", + }, + "licensing_config": { + "is_set": False, + "minting_fee": 1, + "hook_data": "", + "licensing_hook": ZERO_ADDRESS, + "commercial_rev_share": 90, + "disabled": False, + "expect_minimum_group_reward_share": 0, + "expect_group_reward_pool": ZERO_ADDRESS, + }, + }, + ], + deadline=10000, + ) + assert response is not None + assert isinstance(response["tx_hash"], str) + assert len(response["license_terms_ids"]) == 2 + + def test_register_ip_and_attach_pil_terms_and_distribute_royalty_tokens( + self, story_client: StoryClient, nft_collection + ): + """Test registering an existing NFT as IP, attaching PIL terms and distributing royalty tokens with all optional parameters""" + # Mint an NFT first + token_id = mint_by_spg(nft_collection, story_client.web3, story_client.account) + + royalty_shares = [ + RoyaltyShareInput(recipient=account.address, percentage=30.0), + RoyaltyShareInput(recipient=account_2.address, percentage=70.0), + ] -# Add this test class to your existing test_integration_ip_asset.py file + response = story_client.IPAsset.register_ip_and_attach_pil_terms_and_distribute_royalty_tokens( + nft_contract=nft_collection, + token_id=token_id, + license_terms_data=[ + LicenseTermsDataInput( + terms=LicenseTermsInput( + transferable=True, + royalty_policy=ROYALTY_POLICY, + default_minting_fee=10000, + expiration=1000, + commercial_use=True, + commercial_attribution=False, + commercializer_checker=ZERO_ADDRESS, + commercializer_checker_data=ZERO_HASH, + commercial_rev_share=10, + commercial_rev_ceiling=0, + derivatives_allowed=True, + derivatives_attribution=True, + derivatives_approval=False, + derivatives_reciprocal=True, + derivative_rev_ceiling=0, + currency=WIP_TOKEN_ADDRESS, + uri="test case with custom values", + ), + licensing_config=LicensingConfig( + is_set=True, + minting_fee=10000, + licensing_hook=ZERO_ADDRESS, + hook_data=ZERO_HASH, + commercial_rev_share=10, + disabled=False, + expect_minimum_group_reward_share=0, + expect_group_reward_pool=ZERO_ADDRESS, + ), + ) + ], + royalty_shares=royalty_shares, + ip_metadata=COMMON_IP_METADATA, + deadline=1000, + ) + + # Verify all response fields + assert isinstance(response["tx_hash"], str) and response["tx_hash"] + assert isinstance(response["ip_id"], str) and response["ip_id"] + assert ( + isinstance(response["token_id"], int) and response["token_id"] == token_id + ) + assert ( + isinstance(response["license_terms_ids"], list) + and len(response["license_terms_ids"]) > 0 + ) + assert isinstance(response["royalty_vault"], str) and response["royalty_vault"] + assert ( + isinstance(response["distribute_royalty_tokens_tx_hash"], str) + and response["distribute_royalty_tokens_tx_hash"] + ) class TestIPAssetMint: @@ -760,283 +970,60 @@ def test_mint_with_existing_metadata_hash_no_duplicates( allow_duplicates=False, ) - -class TestMintAndRegisterIpAndMakeDerivative: - def test_default_value( - self, story_client: StoryClient, nft_collection, parent_ip_and_license_terms - ): - response = story_client.IPAsset.mint_and_register_ip_and_make_derivative( - spg_nft_contract=nft_collection, - deriv_data=DerivativeDataInput( - parent_ip_ids=[parent_ip_and_license_terms["parent_ip_id"]], - license_terms_ids=[parent_ip_and_license_terms["license_terms_id"]], - ), - ) - assert response is not None - assert isinstance(response["tx_hash"], str) - assert isinstance(response["ip_id"], str) - assert isinstance(response["token_id"], int) - - def test_with_custom_value( - self, story_client: StoryClient, nft_collection, parent_ip_and_license_terms - ): - response = story_client.IPAsset.mint_and_register_ip_and_make_derivative( - spg_nft_contract=nft_collection, - deriv_data=DerivativeDataInput( - parent_ip_ids=[parent_ip_and_license_terms["parent_ip_id"]], - license_terms_ids=[parent_ip_and_license_terms["license_terms_id"]], - max_minting_fee=10000, - max_rts=10, - max_revenue_share=100, - ), - ip_metadata=IPMetadataInput( - ip_metadata_uri="https://example.com/metadata/custom-value.json", - ip_metadata_hash=web3.keccak(text="custom-value-metadata"), - nft_metadata_uri="https://example.com/metadata/custom-value.json", - nft_metadata_hash=web3.keccak(text="custom-value-metadata"), - ), - recipient=account_2.address, - allow_duplicates=False, - ) - assert response is not None - assert isinstance(response["tx_hash"], str) - assert isinstance(response["ip_id"], str) - assert isinstance(response["token_id"], int) - - -class TestMintAndRegisterIpAndMakeDerivativeWithLicenseTokens: - def test_default_value( - self, - story_client: StoryClient, - nft_collection, - mint_and_approve_license_token, + def test_mint_and_register_ip_and_attach_pil_terms_and_distribute_royalty_tokens( + self, story_client: StoryClient, nft_collection ): - # Get second parent ip and license terms - second_parent_ip_and_license_terms = ( - story_client.IPAsset.mint_and_register_ip_asset_with_pil_terms( - spg_nft_contract=nft_collection, - terms=[ - { - "terms": { - "transferable": True, - "royalty_policy": ROYALTY_POLICY_LAP_ADDRESS, - "default_minting_fee": 0, - "expiration": 0, - "commercial_use": True, - "commercial_attribution": False, - "commercializer_checker": ZERO_ADDRESS, - "commercializer_checker_data": ZERO_ADDRESS, - "commercial_rev_share": 50, - "commercial_rev_ceiling": 0, - "derivatives_allowed": True, - "derivatives_attribution": True, - "derivatives_approval": False, - "derivatives_reciprocal": True, - "derivative_rev_ceiling": 0, - "currency": MockERC20, - "uri": "", - }, - "licensing_config": { - "is_set": True, - "minting_fee": 0, - "hook_data": ZERO_ADDRESS, - "licensing_hook": ZERO_ADDRESS, - "commercial_rev_share": 0, - "disabled": False, - "expect_minimum_group_reward_share": 0, - "expect_group_reward_pool": ZERO_ADDRESS, - }, - } - ], - allow_duplicates=True, - ) - ) - # Mint license tokens for second parent ip - second_license_token_ids = story_client.License.mint_license_tokens( - licensor_ip_id=second_parent_ip_and_license_terms["ip_id"], - license_template=PIL_LICENSE_TEMPLATE, - license_terms_id=second_parent_ip_and_license_terms["license_terms_ids"][0], - amount=1, - receiver=account.address, - max_revenue_share=100, - ) - # Approve license tokens for derivative workflows - approve( - erc20_contract_address=LicenseTokenClient( - story_client.web3 - ).contract.address, - web3=story_client.web3, - account=account, - spender_address=DerivativeWorkflowsClient( - story_client.web3 - ).contract.address, - amount=second_license_token_ids["license_token_ids"][0], - ) - # Mint and register ip and make derivative with license tokens - response = story_client.IPAsset.mint_and_register_ip_and_make_derivative_with_license_tokens( - spg_nft_contract=nft_collection, - license_token_ids=[ - mint_and_approve_license_token[0], - second_license_token_ids["license_token_ids"][0], - ], - max_rts=100000000, - ) - assert response is not None - assert isinstance(response["tx_hash"], str) - assert isinstance(response["ip_id"], str) - assert isinstance(response["token_id"], int) + """Test minting NFT, registering IP, attaching PIL terms and distributing royalty tokens with all optional parameters""" + royalty_shares = [ + RoyaltyShareInput(recipient=account_2.address, percentage=50.0) + ] - def test_with_custom_value( - self, - story_client: StoryClient, - nft_collection, - mint_and_approve_license_token, - ): - license_token_ids = mint_and_approve_license_token - response = story_client.IPAsset.mint_and_register_ip_and_make_derivative_with_license_tokens( + response = story_client.IPAsset.mint_and_register_ip_and_attach_pil_terms_and_distribute_royalty_tokens( spg_nft_contract=nft_collection, - license_token_ids=[license_token_ids[1]], - max_rts=100000000, - ip_metadata=IPMetadataInput( - ip_metadata_uri="https://example.com/metadata/custom-value.json", - ip_metadata_hash=web3.keccak(text="custom-value-metadata"), - nft_metadata_uri="https://example.com/metadata/custom-value.json", - nft_metadata_hash=web3.keccak(text="custom-value-metadata"), - ), - recipient=account_2.address, - allow_duplicates=True, - ) - assert response is not None - assert isinstance(response["tx_hash"], str) - assert isinstance(response["ip_id"], str) - assert isinstance(response["token_id"], int) - - -class TestRegisterPilTermsAndAttach: - def test_successful_registration( - self, - story_client: StoryClient, - parent_ip_and_license_terms, - ): - response = story_client.IPAsset.register_pil_terms_and_attach( - ip_id=parent_ip_and_license_terms["parent_ip_id"], license_terms_data=[ - { - "terms": { - "transferable": True, - "royalty_policy": ROYALTY_POLICY, - "default_minting_fee": 1, - "expiration": 0, - "commercial_use": True, - "commercial_attribution": False, - "commercializer_checker": ZERO_ADDRESS, - "commercializer_checker_data": ZERO_ADDRESS, - "commercial_rev_share": 90, - "commercial_rev_ceiling": 0, - "derivatives_allowed": True, - "derivatives_attribution": True, - "derivatives_approval": False, - "derivatives_reciprocal": True, - "derivative_rev_ceiling": 0, - "currency": MockERC20, - "uri": "", - }, - "licensing_config": { + LicenseTermsDataInput( + terms=LicenseTermsInput( + transferable=True, + royalty_policy=ROYALTY_POLICY, + default_minting_fee=10000, + expiration=1000, + commercial_use=True, + commercial_attribution=False, + commercializer_checker=ZERO_ADDRESS, + commercializer_checker_data=ZERO_HASH, + commercial_rev_share=10, + commercial_rev_ceiling=0, + derivatives_allowed=True, + derivatives_attribution=True, + derivatives_approval=False, + derivatives_reciprocal=True, + derivative_rev_ceiling=0, + currency=WIP_TOKEN_ADDRESS, + uri="test case with custom values", + ), + licensing_config={ "is_set": True, - "minting_fee": 1, - "hook_data": "", + "minting_fee": 10000, "licensing_hook": ZERO_ADDRESS, - "commercial_rev_share": 90, - "disabled": False, - "expect_minimum_group_reward_share": 0, - "expect_group_reward_pool": ZERO_ADDRESS, - }, - }, - { - "terms": { - "transferable": True, - "royalty_policy": ROYALTY_POLICY, - "default_minting_fee": 10, - "expiration": 0, - "commercial_use": True, - "commercial_attribution": False, - "commercializer_checker": ZERO_ADDRESS, - "commercializer_checker_data": ZERO_ADDRESS, + "hook_data": ZERO_HASH, "commercial_rev_share": 10, - "commercial_rev_ceiling": 0, - "derivatives_allowed": True, - "derivatives_attribution": True, - "derivatives_approval": False, - "derivatives_reciprocal": True, - "derivative_rev_ceiling": 0, - "currency": MockERC20, - "uri": "", - }, - "licensing_config": { - "is_set": False, - "minting_fee": 1, - "hook_data": "", - "licensing_hook": ZERO_ADDRESS, - "commercial_rev_share": 90, "disabled": False, "expect_minimum_group_reward_share": 0, "expect_group_reward_pool": ZERO_ADDRESS, }, - }, - ], - deadline=10000, - ) - assert response is not None - assert isinstance(response["tx_hash"], str) - assert len(response["license_terms_ids"]) == 2 - - -class TestMintAndRegisterIpAndMakeDerivativeAndDistributeRoyaltyTokens: - def test_mint_register_ip_make_derivative_distribute_royalty_tokens_default_value( - self, story_client: StoryClient, nft_collection, parent_ip_and_license_terms - ): - response = story_client.IPAsset.mint_and_register_ip_and_make_derivative_and_distribute_royalty_tokens( - spg_nft_contract=nft_collection, - deriv_data=DerivativeDataInput( - parent_ip_ids=[parent_ip_and_license_terms["parent_ip_id"]], - license_terms_ids=[parent_ip_and_license_terms["license_terms_id"]], - ), - royalty_shares=[ - RoyaltyShareInput(recipient=account.address, percentage=50.000032222), - RoyaltyShareInput(recipient=account_2.address, percentage=30.000032222), - ], - ) - assert isinstance(response["tx_hash"], str) - assert isinstance(response["ip_id"], str) - assert isinstance(response["token_id"], int) - assert isinstance(response["royalty_vault"], str) - - def test_mint_register_ip_make_derivative_distribute_royalty_tokens_with_custom_values( - self, story_client: StoryClient, nft_collection, parent_ip_and_license_terms - ): - response = story_client.IPAsset.mint_and_register_ip_and_make_derivative_and_distribute_royalty_tokens( - spg_nft_contract=nft_collection, - deriv_data=DerivativeDataInput( - parent_ip_ids=[parent_ip_and_license_terms["parent_ip_id"]], - license_terms_ids=[parent_ip_and_license_terms["license_terms_id"]], - max_minting_fee=10000, - max_rts=10, - max_revenue_share=100, - ), - royalty_shares=[ - RoyaltyShareInput(recipient=account.address, percentage=60), - RoyaltyShareInput(recipient=account_2.address, percentage=40), + ) ], - ip_metadata=IPMetadataInput( - ip_metadata_uri="https://example.com/ip-metadata", - ip_metadata_hash=web3.keccak(text="ip_metadata_hash"), - nft_metadata_uri="https://example.com/nft-metadata", - nft_metadata_hash=web3.keccak(text="nft_metadata_hash"), - ), + royalty_shares=royalty_shares, + ip_metadata=COMMON_IP_METADATA, recipient=account_2.address, allow_duplicates=False, ) - assert isinstance(response["tx_hash"], str) - assert isinstance(response["ip_id"], str) + + assert isinstance(response["tx_hash"], str) and response["tx_hash"] + assert isinstance(response["ip_id"], str) and response["ip_id"] assert isinstance(response["token_id"], int) - assert isinstance(response["royalty_vault"], str) + assert ( + isinstance(response["license_terms_ids"], list) + and len(response["license_terms_ids"]) > 0 + ) + assert isinstance(response["royalty_vault"], str) and response["royalty_vault"] diff --git a/tests/unit/fixtures/data.py b/tests/unit/fixtures/data.py index 9b3efb9..bfca21d 100644 --- a/tests/unit/fixtures/data.py +++ b/tests/unit/fixtures/data.py @@ -1,3 +1,12 @@ +from ens.ens import HexStr + +from story_protocol_python_sdk import ( + IPMetadataInput, + LicenseTermsDataInput, + LicenseTermsInput, +) +from story_protocol_python_sdk.utils.constants import ZERO_ADDRESS, ZERO_HASH + CHAIN_ID = 1315 ADDRESS = "0x1234567890123456789012345678901234567890" TX_HASH = b"tx_hash_bytes" @@ -34,3 +43,43 @@ "expect_group_reward_pool": ADDRESS, } ACCOUNT_ADDRESS = "0xF60cBF0Ea1A61567F1dDaf79A6219D20d189155c" + +LICENSE_TERMS_DATA = [ + LicenseTermsDataInput( + terms=LicenseTermsInput( + transferable=True, + royalty_policy=ADDRESS, + default_minting_fee=10000, + expiration=1000, + commercial_use=True, + commercial_attribution=False, + commercializer_checker=ZERO_ADDRESS, + commercializer_checker_data=ZERO_HASH, + commercial_rev_share=10, + commercial_rev_ceiling=0, + derivatives_allowed=True, + derivatives_attribution=True, + derivatives_approval=False, + derivatives_reciprocal=True, + derivative_rev_ceiling=0, + currency=ADDRESS, + uri="test-uri", + ), + licensing_config={ + "is_set": True, + "minting_fee": 10, + "licensing_hook": ADDRESS, + "hook_data": ZERO_HASH, + "commercial_rev_share": 10, + "disabled": False, + "expect_minimum_group_reward_share": 0, + "expect_group_reward_pool": ZERO_ADDRESS, + }, + ) +] +IP_METADATA = IPMetadataInput( + ip_metadata_uri="https://example.com/ip-metadata.json", + ip_metadata_hash=HexStr("0x" + "a" * 64), + nft_metadata_uri="https://example.com/nft-metadata.json", + nft_metadata_hash=HexStr("0x" + "b" * 64), +) diff --git a/tests/unit/resources/test_ip_asset.py b/tests/unit/resources/test_ip_asset.py index baa3986..a10a515 100644 --- a/tests/unit/resources/test_ip_asset.py +++ b/tests/unit/resources/test_ip_asset.py @@ -3,26 +3,26 @@ import pytest from ens.ens import HexStr +from story_protocol_python_sdk import RoyaltyShareInput from story_protocol_python_sdk.abi.IPAccountImpl.IPAccountImpl_client import ( IPAccountImplClient, ) from story_protocol_python_sdk.resources.IPAsset import IPAsset -from story_protocol_python_sdk.utils.constants import ZERO_HASH from story_protocol_python_sdk.utils.derivative_data import DerivativeDataInput from story_protocol_python_sdk.utils.ip_metadata import IPMetadata, IPMetadataInput -from story_protocol_python_sdk.utils.royalty_shares import ( - RoyaltyShare, - RoyaltyShareInput, -) -from tests.integration.config.utils import ZERO_ADDRESS +from story_protocol_python_sdk.utils.royalty import get_royalty_shares from tests.unit.fixtures.data import ( ACCOUNT_ADDRESS, ADDRESS, CHAIN_ID, IP_ID, + IP_METADATA, LICENSE_TERMS, + LICENSE_TERMS_DATA, LICENSING_CONFIG, TX_HASH, + ZERO_ADDRESS, + ZERO_HASH, ) @@ -213,7 +213,13 @@ def test_success( mock_get_function_signature, mock_license_registry_client, ): - with mock_get_ip_id(), mock_is_registered(), mock_parse_ip_registered_event(), mock_get_function_signature(), mock_license_registry_client(): + with ( + mock_get_ip_id(), + mock_is_registered(), + mock_parse_ip_registered_event(), + mock_get_function_signature(), + mock_license_registry_client(), + ): with mock_signature_related_methods(): result = ip_asset.register_derivative_ip( nft_contract=ADDRESS, @@ -268,7 +274,7 @@ def test_token_id_is_already_registered( ) def test_royalty_policy_commercial_rev_share_is_less_than_0( - self, ip_asset, mock_get_ip_id, mock_is_registered + self, ip_asset: IPAsset, mock_get_ip_id, mock_is_registered ): with mock_get_ip_id(), mock_is_registered(): with pytest.raises( @@ -283,6 +289,7 @@ def test_royalty_policy_commercial_rev_share_is_less_than_0( **LICENSE_TERMS, "commercial_rev_share": -1, }, + "licensing_config": LICENSING_CONFIG, } ], ) @@ -296,7 +303,13 @@ def test_transaction_to_be_called_with_correct_parameters( mock_parse_tx_license_terms_attached_event, mock_signature_related_methods, ): - with mock_get_ip_id(), mock_is_registered(), mock_parse_ip_registered_event(), mock_parse_tx_license_terms_attached_event(), mock_signature_related_methods(): + with ( + mock_get_ip_id(), + mock_is_registered(), + mock_parse_ip_registered_event(), + mock_parse_tx_license_terms_attached_event(), + mock_signature_related_methods(), + ): with patch.object( ip_asset.license_attachment_workflows_client, "build_registerIpAndAttachPILTerms_transaction", @@ -359,7 +372,13 @@ def test_success( mock_signature_related_methods, mock_parse_tx_license_terms_attached_event, ): - with mock_get_ip_id(), mock_is_registered(), mock_parse_ip_registered_event(), mock_parse_tx_license_terms_attached_event(), mock_signature_related_methods(): + with ( + mock_get_ip_id(), + mock_is_registered(), + mock_parse_ip_registered_event(), + mock_parse_tx_license_terms_attached_event(), + mock_signature_related_methods(), + ): result = ip_asset.register_ip_and_attach_pil_terms( nft_contract=ADDRESS, token_id=3, @@ -407,9 +426,12 @@ def test_default_value_when_not_provided( mock_parse_ip_registered_event, mock_license_registry_client, ): - with mock_get_ip_id(), mock_is_registered( - True - ), mock_parse_ip_registered_event(), mock_license_registry_client(): + with ( + mock_get_ip_id(), + mock_is_registered(True), + mock_parse_ip_registered_event(), + mock_license_registry_client(), + ): with patch.object( ip_asset.licensing_module_client, "build_registerDerivative_transaction", @@ -439,9 +461,12 @@ def test_call_value_when_provided( mock_parse_ip_registered_event, mock_license_registry_client, ): - with mock_get_ip_id(), mock_is_registered( - True - ), mock_parse_ip_registered_event(), mock_license_registry_client(): + with ( + mock_get_ip_id(), + mock_is_registered(True), + mock_parse_ip_registered_event(), + mock_license_registry_client(), + ): with patch.object( ip_asset.licensing_module_client, "build_registerDerivative_transaction", @@ -614,8 +639,10 @@ def test_license_token_not_owned_by_caller( self, ip_asset: IPAsset, mock_get_ip_id, mock_is_registered, mock_owner_of ): """Test error when license token is not owned by caller.""" - with mock_get_ip_id(), mock_is_registered(False), mock_owner_of( - "0x1234567890123456789012345678901234567890" + with ( + mock_get_ip_id(), + mock_is_registered(False), + mock_owner_of("0x1234567890123456789012345678901234567890"), ): with pytest.raises( ValueError, match="License token id 1 must be owned by the caller." @@ -653,9 +680,14 @@ def test_success_with_default_values( mock_get_function_signature, ): """Test successful registration with default values.""" - with mock_get_ip_id(), mock_is_registered( - False - ), mock_owner_of(), mock_parse_ip_registered_event(), mock_signature_related_methods(), mock_get_function_signature(): + with ( + mock_get_ip_id(), + mock_is_registered(False), + mock_owner_of(), + mock_parse_ip_registered_event(), + mock_signature_related_methods(), + mock_get_function_signature(), + ): with patch.object( ip_asset.derivative_workflows_client, "build_registerIpAndMakeDerivativeWithLicenseTokens_transaction", @@ -694,9 +726,14 @@ def test_success_with_custom_values( mock_get_function_signature, ): """Test successful registration with custom values.""" - with mock_get_ip_id(), mock_is_registered( - False - ), mock_owner_of(), mock_parse_ip_registered_event(), mock_signature_related_methods(), mock_get_function_signature(): + with ( + mock_get_ip_id(), + mock_is_registered(False), + mock_owner_of(), + mock_parse_ip_registered_event(), + mock_signature_related_methods(), + mock_get_function_signature(), + ): with patch.object( ip_asset.derivative_workflows_client, "build_registerIpAndMakeDerivativeWithLicenseTokens_transaction", @@ -922,9 +959,13 @@ def test_successful_registration( mock_parse_tx_license_terms_attached_event, mock_ip_account_impl_client, ): - with mock_is_registered( - True - ), mock_get_function_signature(), mock_signature_related_methods(), mock_parse_tx_license_terms_attached_event(), mock_ip_account_impl_client(): + with ( + mock_is_registered(True), + mock_get_function_signature(), + mock_signature_related_methods(), + mock_parse_tx_license_terms_attached_event(), + mock_ip_account_impl_client(), + ): with patch.object( ip_asset.license_attachment_workflows_client, "build_registerPILTermsAndAttach_transaction", @@ -986,9 +1027,13 @@ def test_registration_with_transaction_failed( mock_parse_tx_license_terms_attached_event, mock_ip_account_impl_client, ): - with mock_is_registered( - True - ), mock_get_function_signature(), mock_signature_related_methods(), mock_parse_tx_license_terms_attached_event(), mock_ip_account_impl_client(): + with ( + mock_is_registered(True), + mock_get_function_signature(), + mock_signature_related_methods(), + mock_parse_tx_license_terms_attached_event(), + mock_ip_account_impl_client(), + ): with patch.object( ip_asset.license_attachment_workflows_client, "build_registerPILTermsAndAttach_transaction", @@ -1043,7 +1088,11 @@ def test_success_with_default_values( RoyaltyShareInput(recipient=ADDRESS, percentage=30.0), ] - with mock_parse_ip_registered_event(), mock_license_registry_client(), mock_get_royalty_vault_address_by_ip_id(): + with ( + mock_parse_ip_registered_event(), + mock_license_registry_client(), + mock_get_royalty_vault_address_by_ip_id(), + ): with patch.object( ip_asset.royalty_token_distribution_workflows_client, "build_mintAndRegisterIpAndMakeDerivativeAndDistributeRoyaltyTokens_transaction", @@ -1060,8 +1109,7 @@ def test_success_with_default_values( called_args = mock_build_transaction.call_args[0] assert called_args[2] == IPMetadata.from_input().get_validated_data() assert ( - called_args[4] - == RoyaltyShare.get_royalty_shares(royalty_shares)["royalty_shares"] + called_args[4] == get_royalty_shares(royalty_shares)["royalty_shares"] ) assert called_args[5] is True @@ -1082,7 +1130,11 @@ def test_royalty_vault_address( RoyaltyShareInput(recipient=ADDRESS, percentage=30.0), ] - with mock_parse_ip_registered_event(), mock_license_registry_client(), mock_get_royalty_vault_address_by_ip_id(): + with ( + mock_parse_ip_registered_event(), + mock_license_registry_client(), + mock_get_royalty_vault_address_by_ip_id(), + ): with patch( "story_protocol_python_sdk.resources.IPAsset.build_and_send_transaction", return_value={ @@ -1127,7 +1179,11 @@ def test_success_with_custom_values( nft_metadata_uri="https://example.com/nft-metadata", nft_metadata_hash="0xabcdef1234567890", ) - with mock_parse_ip_registered_event(), mock_license_registry_client(), mock_get_royalty_vault_address_by_ip_id(): + with ( + mock_parse_ip_registered_event(), + mock_license_registry_client(), + mock_get_royalty_vault_address_by_ip_id(), + ): with patch.object( ip_asset.royalty_token_distribution_workflows_client, "build_mintAndRegisterIpAndMakeDerivativeAndDistributeRoyaltyTokens_transaction", @@ -1155,8 +1211,7 @@ def test_success_with_custom_values( == IPMetadata.from_input(ip_metadata).get_validated_data() ) assert ( - called_args[4] - == RoyaltyShare.get_royalty_shares(royalty_shares)["royalty_shares"] + called_args[4] == get_royalty_shares(royalty_shares)["royalty_shares"] ) assert called_args[5] is False @@ -1190,3 +1245,350 @@ def test_throw_error_when_transaction_failed( ) ], ) + + +class TestMintAndRegisterIpAndAttachPilTermsAndDistributeRoyaltyTokens: + def test_throw_error_when_royalty_shares_empty(self, ip_asset: IPAsset): + with pytest.raises( + ValueError, + match="Failed to mint, register IP, attach PIL terms and distribute royalty tokens: Royalty shares must be provided.", + ): + ip_asset.mint_and_register_ip_and_attach_pil_terms_and_distribute_royalty_tokens( + spg_nft_contract=ADDRESS, + license_terms_data=LICENSE_TERMS_DATA, + royalty_shares=[], + ) + + def test_success_with_default_values( + self, + ip_asset: IPAsset, + mock_license_registry_client, + mock_parse_ip_registered_event, + mock_parse_tx_license_terms_attached_event, + mock_get_royalty_vault_address_by_ip_id, + ): + royalty_shares = [ + RoyaltyShareInput(recipient=ACCOUNT_ADDRESS, percentage=50.0), + RoyaltyShareInput(recipient=ADDRESS, percentage=30.0), + ] + + with ( + mock_parse_ip_registered_event(), + mock_parse_tx_license_terms_attached_event(), + mock_license_registry_client(), + mock_get_royalty_vault_address_by_ip_id(), + ): + with patch.object( + ip_asset.royalty_token_distribution_workflows_client, + "build_mintAndRegisterIpAndAttachPILTermsAndDistributeRoyaltyTokens_transaction", + return_value={"tx_hash": TX_HASH.hex()}, + ) as mock_build_transaction: + result = ip_asset.mint_and_register_ip_and_attach_pil_terms_and_distribute_royalty_tokens( + spg_nft_contract=ADDRESS, + license_terms_data=LICENSE_TERMS_DATA, + royalty_shares=royalty_shares, + ) + called_args = mock_build_transaction.call_args[0] + assert called_args[2] == IPMetadata.from_input().get_validated_data() + assert ( + called_args[4] == get_royalty_shares(royalty_shares)["royalty_shares"] + ) + assert called_args[5] is True + + assert result["tx_hash"] == TX_HASH.hex() + assert result["ip_id"] == IP_ID + assert result["token_id"] == 3 + assert result["license_terms_ids"] == [1, 2] + assert result["royalty_vault"] == ADDRESS + + def test_success_with_custom_values( + self, + ip_asset: IPAsset, + mock_license_registry_client, + mock_parse_ip_registered_event, + mock_parse_tx_license_terms_attached_event, + mock_get_royalty_vault_address_by_ip_id, + ): + royalty_shares = [ + RoyaltyShareInput(recipient=ACCOUNT_ADDRESS, percentage=50.0), + RoyaltyShareInput(recipient=ADDRESS, percentage=30.0), + ] + custom_recipient = ACCOUNT_ADDRESS + + with ( + mock_parse_ip_registered_event(), + mock_parse_tx_license_terms_attached_event(), + mock_license_registry_client(), + mock_get_royalty_vault_address_by_ip_id(), + ): + with patch.object( + ip_asset.royalty_token_distribution_workflows_client, + "build_mintAndRegisterIpAndAttachPILTermsAndDistributeRoyaltyTokens_transaction", + return_value={"tx_hash": TX_HASH.hex()}, + ) as mock_build_transaction: + result = ip_asset.mint_and_register_ip_and_attach_pil_terms_and_distribute_royalty_tokens( + spg_nft_contract=ADDRESS, + license_terms_data=LICENSE_TERMS_DATA, + royalty_shares=royalty_shares, + ip_metadata=IP_METADATA, + recipient=custom_recipient, + allow_duplicates=False, + ) + called_args = mock_build_transaction.call_args[0] + assert called_args[0] == ADDRESS + assert called_args[1] == custom_recipient + assert ( + called_args[2] + == IPMetadata.from_input(IP_METADATA).get_validated_data() + ) + assert ( + called_args[4] == get_royalty_shares(royalty_shares)["royalty_shares"] + ) + assert called_args[5] is False + + assert result["tx_hash"] == TX_HASH.hex() + assert result["ip_id"] == IP_ID + assert result["token_id"] == 3 + assert result["license_terms_ids"] == [1, 2] + assert result["royalty_vault"] == ADDRESS + + def test_success_with_tx_options( + self, + ip_asset: IPAsset, + mock_license_registry_client, + mock_parse_ip_registered_event, + mock_parse_tx_license_terms_attached_event, + mock_get_royalty_vault_address_by_ip_id, + ): + royalty_shares = [ + RoyaltyShareInput(recipient=ACCOUNT_ADDRESS, percentage=50.0), + RoyaltyShareInput(recipient=ADDRESS, percentage=30.0), + ] + tx_options = {"gas": 500000, "gasPrice": 1000000000} + + with ( + mock_parse_ip_registered_event(), + mock_parse_tx_license_terms_attached_event(), + mock_license_registry_client(), + mock_get_royalty_vault_address_by_ip_id(), + ): + with patch( + "story_protocol_python_sdk.resources.IPAsset.build_and_send_transaction" + ) as mock_build_and_send: + mock_build_and_send.return_value = { + "tx_hash": TX_HASH.hex(), + "tx_receipt": "mock_receipt", + } + result = ip_asset.mint_and_register_ip_and_attach_pil_terms_and_distribute_royalty_tokens( + spg_nft_contract=ADDRESS, + license_terms_data=LICENSE_TERMS_DATA, + royalty_shares=royalty_shares, + tx_options=tx_options, + ) + + called_kwargs = mock_build_and_send.call_args[1] + assert called_kwargs["tx_options"] == tx_options + + assert result["tx_hash"] == TX_HASH.hex() + assert result["ip_id"] == IP_ID + assert result["token_id"] == 3 + assert result["license_terms_ids"] == [1, 2] + assert result["royalty_vault"] == ADDRESS + + +class TestRegisterIpAndAttachPilTermsAndDistributeRoyaltyTokens: + def test_token_id_is_already_registered( + self, ip_asset: IPAsset, mock_get_ip_id, mock_is_registered + ): + with ( + mock_get_ip_id(), + mock_is_registered(True), + ): + with pytest.raises( + ValueError, + match="Failed to register IP, attach PIL terms and distribute royalty tokens: The NFT with id 3 is already registered as IP.", + ): + ip_asset.register_ip_and_attach_pil_terms_and_distribute_royalty_tokens( + nft_contract=ADDRESS, + token_id=3, + license_terms_data=LICENSE_TERMS_DATA, + royalty_shares=[ + RoyaltyShareInput(recipient=ACCOUNT_ADDRESS, percentage=50.0) + ], + ) + + def test_throw_error_when_royalty_shares_empty( + self, ip_asset: IPAsset, mock_get_ip_id, mock_is_registered + ): + with ( + mock_get_ip_id(), + mock_is_registered(), + ): + with pytest.raises( + ValueError, + match="Failed to register IP, attach PIL terms and distribute royalty tokens: Royalty shares must be provided.", + ): + ip_asset.register_ip_and_attach_pil_terms_and_distribute_royalty_tokens( + nft_contract=ADDRESS, + token_id=3, + license_terms_data=LICENSE_TERMS_DATA, + royalty_shares=[], + ) + + def test_success_with_default_values( + self, + ip_asset: IPAsset, + mock_get_ip_id, + mock_is_registered, + mock_parse_ip_registered_event, + mock_parse_tx_license_terms_attached_event, + mock_signature_related_methods, + mock_get_royalty_vault_address_by_ip_id, + mock_ip_account_impl_client, + ): + royalty_shares = [ + RoyaltyShareInput(recipient=ACCOUNT_ADDRESS, percentage=50.0), + RoyaltyShareInput(recipient=ADDRESS, percentage=30.0), + ] + + with ( + mock_get_ip_id(), + mock_is_registered(), + mock_parse_ip_registered_event(), + mock_parse_tx_license_terms_attached_event(), + mock_signature_related_methods(), + mock_get_royalty_vault_address_by_ip_id(), + mock_ip_account_impl_client(), + ): + with ( + patch.object( + ip_asset.royalty_token_distribution_workflows_client, + "build_registerIpAndAttachPILTermsAndDeployRoyaltyVault_transaction", + return_value={"tx_hash": TX_HASH.hex()}, + ), + patch.object( + ip_asset, + "_distribute_royalty_tokens", + return_value=TX_HASH.hex(), + ) as mock_distribute, + ): + result = ip_asset.register_ip_and_attach_pil_terms_and_distribute_royalty_tokens( + nft_contract=ADDRESS, + token_id=3, + license_terms_data=LICENSE_TERMS_DATA, + royalty_shares=royalty_shares, + ) + + # Verify distribute was called with correct arguments + mock_distribute.assert_called_once() + call_kwargs = mock_distribute.call_args[1] + assert call_kwargs["ip_id"] == IP_ID + assert ( + call_kwargs["royalty_shares"] + == get_royalty_shares(royalty_shares)["royalty_shares"] + ) + assert call_kwargs["royalty_vault"] == ADDRESS + assert ( + call_kwargs["total_amount"] + == get_royalty_shares(royalty_shares)["total_amount"] + ) + + assert result["tx_hash"] == TX_HASH.hex() + assert result["ip_id"] == IP_ID + assert result["token_id"] == 3 + assert result["license_terms_ids"] == [1, 2] + assert result["royalty_vault"] == ADDRESS + assert result["distribute_royalty_tokens_tx_hash"] == TX_HASH.hex() + + def test_success_with_custom_values( + self, + ip_asset: IPAsset, + mock_get_ip_id, + mock_is_registered, + mock_parse_ip_registered_event, + mock_parse_tx_license_terms_attached_event, + mock_signature_related_methods, + mock_get_royalty_vault_address_by_ip_id, + mock_ip_account_impl_client, + ): + royalty_shares = [ + RoyaltyShareInput(recipient=ACCOUNT_ADDRESS, percentage=60.0), + ] + + with ( + mock_get_ip_id(), + mock_is_registered(), + mock_parse_ip_registered_event(), + mock_parse_tx_license_terms_attached_event(), + mock_signature_related_methods(), + mock_get_royalty_vault_address_by_ip_id(), + mock_ip_account_impl_client(), + ): + with ( + patch.object( + ip_asset.royalty_token_distribution_workflows_client, + "build_registerIpAndAttachPILTermsAndDeployRoyaltyVault_transaction", + return_value={"tx_hash": TX_HASH.hex()}, + ) as mock_build_transaction, + patch.object( + ip_asset, + "_distribute_royalty_tokens", + return_value=TX_HASH.hex(), + ), + ): + result = ip_asset.register_ip_and_attach_pil_terms_and_distribute_royalty_tokens( + nft_contract=ADDRESS, + token_id=3, + license_terms_data=LICENSE_TERMS_DATA, + royalty_shares=royalty_shares, + ip_metadata=IP_METADATA, + deadline=1000, + ) + + # Verify transaction was called with correct arguments + called_args = mock_build_transaction.call_args[0] + assert called_args[0] == ADDRESS + assert called_args[1] == 3 + assert ( + called_args[2] + == IPMetadata.from_input(IP_METADATA).get_validated_data() + ) + + assert result["tx_hash"] == TX_HASH.hex() + assert result["ip_id"] == IP_ID + assert result["token_id"] == 3 + assert result["license_terms_ids"] == [1, 2] + assert result["royalty_vault"] == ADDRESS + assert result["distribute_royalty_tokens_tx_hash"] == TX_HASH.hex() + + def test_throw_error_when_transaction_failed( + self, + ip_asset: IPAsset, + mock_get_ip_id, + mock_is_registered, + mock_signature_related_methods, + ): + with ( + mock_get_ip_id(), + mock_is_registered(), + mock_signature_related_methods(), + ): + with patch.object( + ip_asset.royalty_token_distribution_workflows_client, + "build_registerIpAndAttachPILTermsAndDeployRoyaltyVault_transaction", + side_effect=Exception("Transaction failed."), + ): + with pytest.raises( + ValueError, + match="Failed to register IP, attach PIL terms and distribute royalty tokens: Transaction failed.", + ): + ip_asset.register_ip_and_attach_pil_terms_and_distribute_royalty_tokens( + nft_contract=ADDRESS, + token_id=3, + license_terms_data=LICENSE_TERMS_DATA, + royalty_shares=[ + RoyaltyShareInput( + recipient=ACCOUNT_ADDRESS, percentage=50.0 + ) + ], + ) diff --git a/tests/unit/utils/test_royalty_shares.py b/tests/unit/utils/test_royalty.py similarity index 80% rename from tests/unit/utils/test_royalty_shares.py rename to tests/unit/utils/test_royalty.py index 332c94a..d012a06 100644 --- a/tests/unit/utils/test_royalty_shares.py +++ b/tests/unit/utils/test_royalty.py @@ -1,15 +1,54 @@ -"""Tests for royalty_shares module.""" +"""Tests for royalty module.""" import pytest -from story_protocol_python_sdk.utils.royalty_shares import ( - RoyaltyShare, +from story_protocol_python_sdk.types.resource.Royalty import ( + NativeRoyaltyPolicy, RoyaltyShareInput, ) +from story_protocol_python_sdk.utils.constants import ( + ROYALTY_POLICY_LAP_ADDRESS, + ROYALTY_POLICY_LRP_ADDRESS, +) +from story_protocol_python_sdk.utils.royalty import ( + get_royalty_shares, + royalty_policy_input_to_address, +) + + +class TestRoyaltyPolicyInputToAddress: + """Test royalty_policy_input_to_address function.""" + + def test_none_input_returns_lap_address(self): + """Test that None input returns LAP address""" + result = royalty_policy_input_to_address(None) + assert result == ROYALTY_POLICY_LAP_ADDRESS + + def test_lap_enum_returns_lap_address(self): + """Test that NativeRoyaltyPolicy.LAP returns LAP address""" + result = royalty_policy_input_to_address(NativeRoyaltyPolicy.LAP) + assert result == ROYALTY_POLICY_LAP_ADDRESS + + def test_lrp_enum_returns_lrp_address(self): + """Test that NativeRoyaltyPolicy.LRP returns LRP address""" + result = royalty_policy_input_to_address(NativeRoyaltyPolicy.LRP) + assert result == ROYALTY_POLICY_LRP_ADDRESS + + def test_valid_custom_address_returns_checksum_address(self): + """Test that valid custom address returns checksum format address""" + custom_address = "0x1234567890123456789012345678901234567890" + result = royalty_policy_input_to_address(custom_address) + assert result == "0x1234567890123456789012345678901234567890" + + def test_invalid_custom_address_raises_error(self): + """Test that invalid custom address raises ValueError""" + invalid_address = "invalid_address" + with pytest.raises(ValueError): + royalty_policy_input_to_address(invalid_address) -class TestRoyaltyShareGetRoyaltyShares: - """Test RoyaltyShare.get_royalty_shares method.""" +class TestGetRoyaltyShares: + """Test get_royalty_shares function.""" def test_get_royalty_shares_success(self): """Test successful processing of valid royalty shares.""" @@ -22,7 +61,7 @@ def test_get_royalty_shares_success(self): ), ] - result = RoyaltyShare.get_royalty_shares(shares) + result = get_royalty_shares(shares) expected_shares = [ { @@ -49,7 +88,7 @@ def test_get_royalty_shares_with_integer_percentages(self): ), ] - result = RoyaltyShare.get_royalty_shares(shares) + result = get_royalty_shares(shares) expected_shares = [ { "recipient": "0x1234567890123456789012345678901234567890", @@ -76,7 +115,7 @@ def test_get_royalty_shares_precision_handling_6_decimals(self): ), ] - result = RoyaltyShare.get_royalty_shares(shares) + result = get_royalty_shares(shares) # 33.333333 * 10^6 = 33333333 # 66.666667 * 10^6 = 66666667 @@ -107,7 +146,7 @@ def test_get_royalty_shares_precision_loss_more_than_6_decimals(self): ), ] - result = RoyaltyShare.get_royalty_shares(shares) + result = get_royalty_shares(shares) # Due to floating point precision and int() truncation: # 33.3333333333 * 10^6 = 33333333.3333, int() = 33333333 @@ -131,7 +170,7 @@ def test_get_royalty_shares_very_small_percentages(self): ), ] - result = RoyaltyShare.get_royalty_shares(shares) + result = get_royalty_shares(shares) # 0.000001 * 10^6 = 1 # 99.999999 * 10^6 = 99999999 @@ -147,7 +186,7 @@ def test_get_royalty_shares_boundary_case_exactly_100_percent(self): ) ] - result = RoyaltyShare.get_royalty_shares(shares) + result = get_royalty_shares(shares) assert result["royalty_shares"][0]["percentage"] == 100_000_000 assert result["total_amount"] == 100_000_000 @@ -161,7 +200,7 @@ def test_get_royalty_shares_boundary_case_minimum_percentage(self): ) ] - result = RoyaltyShare.get_royalty_shares(shares) + result = get_royalty_shares(shares) assert result["royalty_shares"][0]["percentage"] == 1 assert result["total_amount"] == 1 @@ -169,7 +208,7 @@ def test_get_royalty_shares_boundary_case_minimum_percentage(self): def test_get_royalty_shares_empty_list_error(self): """Test error when providing empty royalty shares list.""" with pytest.raises(ValueError, match="Royalty shares must be provided."): - RoyaltyShare.get_royalty_shares([]) + get_royalty_shares([]) def test_get_royalty_shares_zero_percentage(self): """Test error when percentage is zero.""" @@ -179,7 +218,7 @@ def test_get_royalty_shares_zero_percentage(self): ) ] - result = RoyaltyShare.get_royalty_shares(shares) + result = get_royalty_shares(shares) assert result["royalty_shares"][0]["percentage"] == 0 assert result["total_amount"] == 0 @@ -196,7 +235,7 @@ def test_get_royalty_shares_negative_percentage_error(self): ValueError, match="he percentage of the royalty shares must be greater than or equal to 0.", ): - RoyaltyShare.get_royalty_shares(shares) + get_royalty_shares(shares) def test_get_royalty_shares_percentage_100(self): """Test when percentage is 100.""" @@ -206,7 +245,7 @@ def test_get_royalty_shares_percentage_100(self): ) ] - result = RoyaltyShare.get_royalty_shares(shares) + result = get_royalty_shares(shares) assert result["royalty_shares"][0]["percentage"] == 100_000_000 assert result["total_amount"] == 100_000_000 @@ -223,14 +262,14 @@ def test_get_royalty_shares_percentage_over_100(self): ValueError, match="The percentage of the royalty shares must be less than or equal to 100.", ): - RoyaltyShare.get_royalty_shares(shares) + get_royalty_shares(shares) def test_get_royalty_shares_invalid_address_error(self): """Test error when address is invalid.""" shares = [RoyaltyShareInput(recipient="invalid_address", percentage=50)] with pytest.raises(ValueError, match="Invalid address"): - RoyaltyShare.get_royalty_shares(shares) + get_royalty_shares(shares) def test_get_royalty_shares_cumulative_precision_boundary(self): """Test cumulative precision at the boundary of 100%.""" @@ -252,7 +291,7 @@ def test_get_royalty_shares_cumulative_precision_boundary(self): ] # This should work because 33.333333 + 33.333333 + 33.333334 = 100.0 - result = RoyaltyShare.get_royalty_shares(shares) + result = get_royalty_shares(shares) assert len(result["royalty_shares"]) == 3 assert result["total_amount"] == 100_000_000 @@ -274,7 +313,7 @@ def test_get_royalty_shares_precision_edge_case_just_over_100(self): with pytest.raises( ValueError, match="The sum of the royalty shares cannot exceeds 100." ): - RoyaltyShare.get_royalty_shares(shares) + get_royalty_shares(shares) def test_get_royalty_shares_single_recipient_multiple_entries(self): """Test multiple entries for the same recipient.""" @@ -290,7 +329,7 @@ def test_get_royalty_shares_single_recipient_multiple_entries(self): ), ] - result = RoyaltyShare.get_royalty_shares(shares) + result = get_royalty_shares(shares) # Should treat each entry separately, not merge them assert len(result["royalty_shares"]) == 3 @@ -310,7 +349,7 @@ def test_get_royalty_shares_mixed_data_types(self): ), # float ] - result = RoyaltyShare.get_royalty_shares(shares) + result = get_royalty_shares(shares) assert result["royalty_shares"][0]["percentage"] == 25_000_000 assert result["royalty_shares"][1]["percentage"] == 75_000_000