From 0520ce6fcc59f2e4f2d58bba2f86f42623197585 Mon Sep 17 00:00:00 2001 From: Bonnie Date: Fri, 17 Oct 2025 14:58:02 +0800 Subject: [PATCH 1/3] test: add integration test for registering IP, attaching PIL terms, and distributing royalty tokens in IPAsset class --- src/story_protocol_python_sdk/__init__.py | 2 + .../resources/IPAsset.py | 186 ++++++++++++++++++ .../types/resource/IPAsset.py | 15 ++ .../integration/test_integration_ip_asset.py | 70 +++++++ 4 files changed, 273 insertions(+) diff --git a/src/story_protocol_python_sdk/__init__.py b/src/story_protocol_python_sdk/__init__.py index c7dd0a5..6e2d56e 100644 --- a/src/story_protocol_python_sdk/__init__.py +++ b/src/story_protocol_python_sdk/__init__.py @@ -15,6 +15,7 @@ ) from .types.resource.IPAsset import ( LicenseTermsDataInput, + RegisterAndAttachAndDistributeRoyaltyTokensResponse, RegisterPILTermsAndAttachResponse, RegistrationResponse, RegistrationWithRoyaltyVaultAndLicenseTermsResponse, @@ -50,6 +51,7 @@ "RegistrationResponse", "RegistrationWithRoyaltyVaultResponse", "RegistrationWithRoyaltyVaultAndLicenseTermsResponse", + "RegisterAndAttachAndDistributeRoyaltyTokensResponse", "LicenseTermsDataInput", "ClaimRewardsResponse", "ClaimReward", diff --git a/src/story_protocol_python_sdk/resources/IPAsset.py b/src/story_protocol_python_sdk/resources/IPAsset.py index 08b9aae..b7781ed 100644 --- a/src/story_protocol_python_sdk/resources/IPAsset.py +++ b/src/story_protocol_python_sdk/resources/IPAsset.py @@ -20,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, ) @@ -48,6 +51,7 @@ from story_protocol_python_sdk.types.common import AccessPermission from story_protocol_python_sdk.types.resource.IPAsset import ( LicenseTermsDataInput, + RegisterAndAttachAndDistributeRoyaltyTokensResponse, RegisterPILTermsAndAttachResponse, RegistrationResponse, RegistrationWithRoyaltyVaultAndLicenseTermsResponse, @@ -1095,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, @@ -1261,6 +1379,74 @@ 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[dict], + 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[dict]: 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: + # Get IP account state for signature + ip_account_impl_client = IPAccountImplClient(self.web3, ip_id) + state = ip_account_impl_client.state() + + # Create IpRoyaltyVaultImpl client instance + ip_royalty_vault_client = IpRoyaltyVaultImplClient(self.web3, royalty_vault) + + # Get signature for approving royalty token transfers + 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, + ) + + # Build and send the distribute transaction + 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. diff --git a/src/story_protocol_python_sdk/types/resource/IPAsset.py b/src/story_protocol_python_sdk/types/resource/IPAsset.py index 582c2b6..3e7c21a 100644 --- a/src/story_protocol_python_sdk/types/resource/IPAsset.py +++ b/src/story_protocol_python_sdk/types/resource/IPAsset.py @@ -63,6 +63,21 @@ class RegisterPILTermsAndAttachResponse(TypedDict): 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: """ diff --git a/tests/integration/test_integration_ip_asset.py b/tests/integration/test_integration_ip_asset.py index da74da6..6b241f7 100644 --- a/tests/integration/test_integration_ip_asset.py +++ b/tests/integration/test_integration_ip_asset.py @@ -16,6 +16,7 @@ from story_protocol_python_sdk.abi.LicenseToken.LicenseToken_client import ( LicenseTokenClient, ) +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 @@ -692,6 +693,75 @@ def test_register_pil_terms_and_attach( 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), + ] + + 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: @pytest.fixture(scope="module") From 078d9abcf213cdefeee3a90657d5691c8c324bdd Mon Sep 17 00:00:00 2001 From: Bonnie Date: Fri, 17 Oct 2025 17:19:06 +0800 Subject: [PATCH 2/3] feat: add IPMetadataInput and related tests for registering IP with metadata in IPAsset class --- tests/unit/fixtures/data.py | 14 +- tests/unit/resources/test_ip_asset.py | 317 +++++++++++++++++++++++--- 2 files changed, 296 insertions(+), 35 deletions(-) diff --git a/tests/unit/fixtures/data.py b/tests/unit/fixtures/data.py index 17edef0..bfca21d 100644 --- a/tests/unit/fixtures/data.py +++ b/tests/unit/fixtures/data.py @@ -1,4 +1,10 @@ -from story_protocol_python_sdk import LicenseTermsDataInput, LicenseTermsInput +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 @@ -71,3 +77,9 @@ }, ) ] +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 306e3f2..a10a515 100644 --- a/tests/unit/resources/test_ip_asset.py +++ b/tests/unit/resources/test_ip_asset.py @@ -16,6 +16,7 @@ ADDRESS, CHAIN_ID, IP_ID, + IP_METADATA, LICENSE_TERMS, LICENSE_TERMS_DATA, LICENSING_CONFIG, @@ -212,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, @@ -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", @@ -1081,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={ @@ -1126,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", @@ -1256,12 +1313,6 @@ def test_success_with_custom_values( RoyaltyShareInput(recipient=ACCOUNT_ADDRESS, percentage=50.0), RoyaltyShareInput(recipient=ADDRESS, percentage=30.0), ] - custom_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), - ) custom_recipient = ACCOUNT_ADDRESS with ( @@ -1279,7 +1330,7 @@ def test_success_with_custom_values( spg_nft_contract=ADDRESS, license_terms_data=LICENSE_TERMS_DATA, royalty_shares=royalty_shares, - ip_metadata=custom_metadata, + ip_metadata=IP_METADATA, recipient=custom_recipient, allow_duplicates=False, ) @@ -1288,7 +1339,7 @@ def test_success_with_custom_values( assert called_args[1] == custom_recipient assert ( called_args[2] - == IPMetadata.from_input(custom_metadata).get_validated_data() + == IPMetadata.from_input(IP_METADATA).get_validated_data() ) assert ( called_args[4] == get_royalty_shares(royalty_shares)["royalty_shares"] @@ -1343,3 +1394,201 @@ def test_success_with_tx_options( 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 + ) + ], + ) From 265b5bc75969c56426292b46b92c9699c7f10235 Mon Sep 17 00:00:00 2001 From: Bonnie Date: Tue, 21 Oct 2025 09:49:44 +0800 Subject: [PATCH 3/3] fix: update type annotation for royalty_shares parameter in _distribute_royalty_tokens method to use RoyaltyShareInput for improved type safety --- src/story_protocol_python_sdk/resources/IPAsset.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/story_protocol_python_sdk/resources/IPAsset.py b/src/story_protocol_python_sdk/resources/IPAsset.py index b7781ed..d191cb9 100644 --- a/src/story_protocol_python_sdk/resources/IPAsset.py +++ b/src/story_protocol_python_sdk/resources/IPAsset.py @@ -1382,7 +1382,7 @@ def _validate_license_token_ids(self, license_token_ids: list) -> list: def _distribute_royalty_tokens( self, ip_id: Address, - royalty_shares: list[dict], + royalty_shares: list[RoyaltyShareInput], deadline: int, royalty_vault: Address, total_amount: int, @@ -1395,7 +1395,7 @@ def _distribute_royalty_tokens( from an IP's royalty vault to the specified recipients. :param ip_id Address: The IP ID. - :param royalty_shares list[dict]: The validated royalty shares with recipient and percentage. + :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. @@ -1403,14 +1403,11 @@ def _distribute_royalty_tokens( :return HexStr: The transaction hash. """ try: - # Get IP account state for signature ip_account_impl_client = IPAccountImplClient(self.web3, ip_id) state = ip_account_impl_client.state() - # Create IpRoyaltyVaultImpl client instance ip_royalty_vault_client = IpRoyaltyVaultImplClient(self.web3, royalty_vault) - # Get signature for approving royalty token transfers signature_response = self.sign_util.get_signature( state=state, to=royalty_vault, @@ -1425,7 +1422,6 @@ def _distribute_royalty_tokens( deadline=deadline, ) - # Build and send the distribute transaction response = build_and_send_transaction( self.web3, self.account,