From 3621c884ef2a8dc89765f667f4eba15db3f5075c Mon Sep 17 00:00:00 2001 From: Bonnie Date: Tue, 21 Oct 2025 14:09:33 +0800 Subject: [PATCH 1/5] feat: add register_derivative_ip_and_attach_pil_terms_and_distribute_royalty_tokens method to IPAsset class --- .../resources/IPAsset.py | 124 +++++++++++++++--- 1 file changed, 109 insertions(+), 15 deletions(-) diff --git a/src/story_protocol_python_sdk/resources/IPAsset.py b/src/story_protocol_python_sdk/resources/IPAsset.py index d191cb9..430032c 100644 --- a/src/story_protocol_python_sdk/resources/IPAsset.py +++ b/src/story_protocol_python_sdk/resources/IPAsset.py @@ -52,6 +52,7 @@ from story_protocol_python_sdk.types.resource.IPAsset import ( LicenseTermsDataInput, RegisterAndAttachAndDistributeRoyaltyTokensResponse, + RegisterDerivativeIPAndAttachAndDistributeRoyaltyTokensResponse, RegisterPILTermsAndAttachResponse, RegistrationResponse, RegistrationWithRoyaltyVaultAndLicenseTermsResponse, @@ -222,12 +223,10 @@ def register( ], ) - signature = self.web3.to_bytes(hexstr=signature_response["signature"]) - req_object["sigMetadata"] = { "signer": self.web3.to_checksum_address(self.account.address), "deadline": calculated_deadline, - "signature": signature, + "signature": signature_response["signature"], } response = build_and_send_transaction( @@ -681,9 +680,7 @@ def register_ip_and_attach_pil_terms( { "signer": self.web3.to_checksum_address(self.account.address), "deadline": calculated_deadline, - "signature": self.web3.to_bytes( - hexstr=signature_response["signature"] - ), + "signature": signature_response["signature"], }, tx_options=tx_options, ) @@ -959,9 +956,7 @@ def register_ip_and_make_derivative_with_license_tokens( { "signer": self.web3.to_checksum_address(self.account.address), "deadline": calculated_deadline, - "signature": self.web3.to_bytes( - hexstr=signature_response["signature"] - ), + "signature": signature_response["signature"], }, tx_options=tx_options, ) @@ -1099,6 +1094,109 @@ 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_derivative_ip_and_attach_pil_terms_and_distribute_royalty_tokens( + self, + nft_contract: Address, + token_id: int, + deriv_data: DerivativeDataInput, + royalty_shares: list[RoyaltyShareInput], + ip_metadata: IPMetadataInput | None = None, + deadline: int = 1000, + tx_options: dict | None = None, + ) -> RegisterDerivativeIPAndAttachAndDistributeRoyaltyTokensResponse: + """ + Register the given NFT as a derivative IP, attach license terms from parent IPs, 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 deriv_data `DerivativeDataInput`: The derivative data to be used for register derivative. + :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." + ) + + validated_deriv_data = DerivativeData.from_input( + web3=self.web3, input_data=deriv_data + ).get_validated_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": "registerDerivative(address,address[],uint256[],address,bytes,uint256,uint32,uint32)", + }, + ], + ) + + response = build_and_send_transaction( + self.web3, + self.account, + self.royalty_token_distribution_workflows_client.build_registerIpAndMakeDerivativeAndDeployRoyaltyVault_transaction, + nft_contract, + token_id, + IPMetadata.from_input(ip_metadata).get_validated_data(), + validated_deriv_data, + { + "signer": self.web3.to_checksum_address(self.account.address), + "deadline": calculated_deadline, + "signature": signature_response["signature"], + }, + tx_options=tx_options, + ) + + ip_registered = self._parse_tx_ip_registered_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 RegisterDerivativeIPAndAttachAndDistributeRoyaltyTokensResponse( + tx_hash=response["tx_hash"], + ip_id=ip_registered["ip_id"], + token_id=ip_registered["token_id"], + royalty_vault=royalty_vault, + distribute_royalty_tokens_tx_hash=distribute_tx_hash, + ) + except Exception as e: + raise ValueError( + f"Failed to register derivative IP and distribute royalty tokens: {str(e)}" + ) from e + def register_ip_and_attach_pil_terms_and_distribute_royalty_tokens( self, nft_contract: Address, @@ -1175,9 +1273,7 @@ def register_ip_and_attach_pil_terms_and_distribute_royalty_tokens( { "signer": self.web3.to_checksum_address(self.account.address), "deadline": calculated_deadline, - "signature": self.web3.to_bytes( - hexstr=signature_response["signature"] - ), + "signature": signature_response["signature"], }, tx_options=tx_options, ) @@ -1431,9 +1527,7 @@ def _distribute_royalty_tokens( { "signer": self.web3.to_checksum_address(self.account.address), "deadline": deadline, - "signature": self.web3.to_bytes( - hexstr=signature_response["signature"] - ), + "signature": signature_response["signature"], }, tx_options=tx_options, ) From 6ea7e350a0e6cbe3847101804396918bc006ea27 Mon Sep 17 00:00:00 2001 From: Bonnie Date: Tue, 21 Oct 2025 15:22:33 +0800 Subject: [PATCH 2/5] feat: add RegisterDerivativeIPAndAttachAndDistributeRoyaltyTokensResponse to IPAsset imports --- src/story_protocol_python_sdk/__init__.py | 2 ++ .../types/resource/IPAsset.py | 17 +++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/src/story_protocol_python_sdk/__init__.py b/src/story_protocol_python_sdk/__init__.py index 6e2d56e..0211344 100644 --- a/src/story_protocol_python_sdk/__init__.py +++ b/src/story_protocol_python_sdk/__init__.py @@ -16,6 +16,7 @@ from .types.resource.IPAsset import ( LicenseTermsDataInput, RegisterAndAttachAndDistributeRoyaltyTokensResponse, + RegisterDerivativeIPAndAttachAndDistributeRoyaltyTokensResponse, RegisterPILTermsAndAttachResponse, RegistrationResponse, RegistrationWithRoyaltyVaultAndLicenseTermsResponse, @@ -52,6 +53,7 @@ "RegistrationWithRoyaltyVaultResponse", "RegistrationWithRoyaltyVaultAndLicenseTermsResponse", "RegisterAndAttachAndDistributeRoyaltyTokensResponse", + "RegisterDerivativeIPAndAttachAndDistributeRoyaltyTokensResponse", "LicenseTermsDataInput", "ClaimRewardsResponse", "ClaimReward", diff --git a/src/story_protocol_python_sdk/types/resource/IPAsset.py b/src/story_protocol_python_sdk/types/resource/IPAsset.py index 3e7c21a..6ec66f2 100644 --- a/src/story_protocol_python_sdk/types/resource/IPAsset.py +++ b/src/story_protocol_python_sdk/types/resource/IPAsset.py @@ -78,6 +78,23 @@ class RegisterAndAttachAndDistributeRoyaltyTokensResponse( distribute_royalty_tokens_tx_hash: HexStr +class RegisterDerivativeIPAndAttachAndDistributeRoyaltyTokensResponse( + RegistrationResponse +): + """ + Response structure for derivative IP and attach PIL terms and distribute royalty tokens. + + Extends `RegistrationResponse` with distribute royalty tokens transaction hash. + + Attributes: + distribute_royalty_tokens_tx_hash: The transaction hash of the distribute royalty tokens transaction. + royalty_vault: The royalty vault address of the registered IP asset. + """ + + distribute_royalty_tokens_tx_hash: HexStr + royalty_vault: Address + + @dataclass class LicenseTermsDataInput: """ From 798dbd73ec6dffeb275199a639ef671b5afdc8e5 Mon Sep 17 00:00:00 2001 From: Bonnie Date: Tue, 21 Oct 2025 15:23:15 +0800 Subject: [PATCH 3/5] feat: add integration test for registering derivative IP and distributing royalty tokens with optional parameters --- .../integration/test_integration_ip_asset.py | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/tests/integration/test_integration_ip_asset.py b/tests/integration/test_integration_ip_asset.py index 6b241f7..24e40f9 100644 --- a/tests/integration/test_integration_ip_asset.py +++ b/tests/integration/test_integration_ip_asset.py @@ -762,6 +762,43 @@ def test_register_ip_and_attach_pil_terms_and_distribute_royalty_tokens( and response["distribute_royalty_tokens_tx_hash"] ) + def test_register_derivative_ip_and_attach_pil_terms_and_distribute_royalty_tokens( + self, story_client: StoryClient, nft_collection, parent_ip_and_license_terms + ): + """Test registering an existing NFT as derivative IP 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=40.0), + RoyaltyShareInput(recipient=account_2.address, percentage=60.0), + ] + + response = story_client.IPAsset.register_derivative_ip_and_attach_pil_terms_and_distribute_royalty_tokens( + nft_contract=nft_collection, + token_id=token_id, + 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=royalty_shares, + ip_metadata=COMMON_IP_METADATA, + deadline=1000, + ) + 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["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 454cee5df4a71fd7c71b39622a6cb8697c7c371d Mon Sep 17 00:00:00 2001 From: Bonnie Date: Tue, 21 Oct 2025 16:44:30 +0800 Subject: [PATCH 4/5] feat: add unit tests for registering derivative IP and distributing royalty tokens with error handling and custom parameters --- tests/unit/resources/test_ip_asset.py | 229 ++++++++++++++++++++++++++ 1 file changed, 229 insertions(+) diff --git a/tests/unit/resources/test_ip_asset.py b/tests/unit/resources/test_ip_asset.py index a10a515..0f49d21 100644 --- a/tests/unit/resources/test_ip_asset.py +++ b/tests/unit/resources/test_ip_asset.py @@ -1592,3 +1592,232 @@ def test_throw_error_when_transaction_failed( ) ], ) + + +class TestRegisterDerivativeIpAndAttachPilTermsAndDistributeRoyaltyTokens: + 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 derivative IP and distribute royalty tokens: The NFT with id 3 is already registered as IP.", + ): + ip_asset.register_derivative_ip_and_attach_pil_terms_and_distribute_royalty_tokens( + nft_contract=ADDRESS, + token_id=3, + deriv_data=DerivativeDataInput( + parent_ip_ids=[IP_ID], + license_terms_ids=[1], + ), + 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, + mock_license_registry_client, + ): + with ( + mock_get_ip_id(), + mock_is_registered(), + mock_license_registry_client(), + ): + with pytest.raises( + ValueError, + match="Failed to register derivative IP and distribute royalty tokens: Royalty shares must be provided.", + ): + ip_asset.register_derivative_ip_and_attach_pil_terms_and_distribute_royalty_tokens( + nft_contract=ADDRESS, + token_id=3, + deriv_data=DerivativeDataInput( + parent_ip_ids=[IP_ID], + license_terms_ids=[1], + ), + 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_signature_related_methods, + mock_get_royalty_vault_address_by_ip_id, + mock_ip_account_impl_client, + mock_license_registry_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_signature_related_methods(), + mock_get_royalty_vault_address_by_ip_id(), + mock_ip_account_impl_client(), + mock_license_registry_client(), + ): + with ( + patch.object( + ip_asset.royalty_token_distribution_workflows_client, + "build_registerIpAndMakeDerivativeAndDeployRoyaltyVault_transaction", + return_value={"tx_hash": TX_HASH.hex()}, + ) as mock_build_transaction, + patch.object( + ip_asset, + "_distribute_royalty_tokens", + return_value=TX_HASH.hex(), + ) as mock_distribute, + ): + result = ip_asset.register_derivative_ip_and_attach_pil_terms_and_distribute_royalty_tokens( + nft_contract=ADDRESS, + token_id=3, + deriv_data=DerivativeDataInput( + parent_ip_ids=[IP_ID], + license_terms_ids=[1], + ), + royalty_shares=royalty_shares, + ) + + called_args = mock_build_transaction.call_args[0] + # assert registerIpAndMakeDerivativeAndDeployRoyaltyVault_transaction was called with correct arguments + assert called_args[0] == ADDRESS + assert called_args[1] == 3 + assert called_args[2] == IPMetadata.from_input().get_validated_data() + + # assert _distribute_royalty_tokens was called with correct arguments + called_kwargs = mock_distribute.call_args[1] + assert called_kwargs["ip_id"] == IP_ID + assert ( + called_kwargs["royalty_shares"] + == get_royalty_shares(royalty_shares)["royalty_shares"] + ) + assert called_kwargs["royalty_vault"] == ADDRESS + + assert result["tx_hash"] == TX_HASH.hex() + assert result["ip_id"] == IP_ID + assert result["token_id"] == 3 + 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_signature_related_methods, + mock_get_royalty_vault_address_by_ip_id, + mock_ip_account_impl_client, + mock_license_registry_client, + ): + royalty_shares = [ + RoyaltyShareInput(recipient=ACCOUNT_ADDRESS, percentage=60.0), + ] + + with ( + mock_get_ip_id(), + mock_is_registered(), + mock_parse_ip_registered_event(), + mock_signature_related_methods(), + mock_get_royalty_vault_address_by_ip_id(), + mock_ip_account_impl_client(), + mock_license_registry_client(), + ): + with ( + patch.object( + ip_asset.royalty_token_distribution_workflows_client, + "build_registerIpAndMakeDerivativeAndDeployRoyaltyVault_transaction", + return_value={"tx_hash": TX_HASH.hex()}, + ) as mock_build_transaction, + patch.object( + ip_asset, + "_distribute_royalty_tokens", + return_value=TX_HASH.hex(), + ) as mock_distribute, + ): + result = ip_asset.register_derivative_ip_and_attach_pil_terms_and_distribute_royalty_tokens( + nft_contract=ADDRESS, + token_id=3, + deriv_data=DerivativeDataInput( + parent_ip_ids=[IP_ID, IP_ID], + license_terms_ids=[1, 2], + max_minting_fee=10000, + max_rts=10, + max_revenue_share=100, + ), + royalty_shares=royalty_shares, + ip_metadata=IP_METADATA, + deadline=100000, + ) + # assert registerIpAndMakeDerivativeAndDeployRoyaltyVault_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 _distribute_royalty_tokens was called with correct arguments + called_kwargs = mock_distribute.call_args[1] + assert called_kwargs["ip_id"] == IP_ID + assert ( + called_kwargs["royalty_shares"] + == get_royalty_shares(royalty_shares)["royalty_shares"] + ) + + assert result["tx_hash"] == TX_HASH.hex() + assert result["ip_id"] == IP_ID + assert result["token_id"] == 3 + 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, + mock_license_registry_client, + ): + with ( + mock_get_ip_id(), + mock_is_registered(), + mock_signature_related_methods(), + mock_license_registry_client(), + ): + with patch.object( + ip_asset.royalty_token_distribution_workflows_client, + "build_registerIpAndMakeDerivativeAndDeployRoyaltyVault_transaction", + side_effect=Exception("Transaction failed."), + ): + with pytest.raises( + ValueError, + match="Failed to register derivative IP and distribute royalty tokens: Transaction failed.", + ): + ip_asset.register_derivative_ip_and_attach_pil_terms_and_distribute_royalty_tokens( + nft_contract=ADDRESS, + token_id=3, + deriv_data=DerivativeDataInput( + parent_ip_ids=[IP_ID], + license_terms_ids=[1], + ), + royalty_shares=[ + RoyaltyShareInput( + recipient=ACCOUNT_ADDRESS, percentage=50.0 + ) + ], + ) From b7bb0e8b7abb200731804287bf88e9a034782e78 Mon Sep 17 00:00:00 2001 From: Bonnie Date: Tue, 21 Oct 2025 17:10:30 +0800 Subject: [PATCH 5/5] fix: update return type in IPAsset documentation and add unit test for transaction options in register_derivative_ip_and_attach_pil_terms_and_distribute_royalty_tokens method --- .../resources/IPAsset.py | 2 +- tests/unit/resources/test_ip_asset.py | 50 +++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/src/story_protocol_python_sdk/resources/IPAsset.py b/src/story_protocol_python_sdk/resources/IPAsset.py index 430032c..802e69e 100644 --- a/src/story_protocol_python_sdk/resources/IPAsset.py +++ b/src/story_protocol_python_sdk/resources/IPAsset.py @@ -1115,7 +1115,7 @@ def register_derivative_ip_and_attach_pil_terms_and_distribute_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. + :return `RegisterDerivativeIPAndAttachAndDistributeRoyaltyTokensResponse`: Response with tx hash, IP ID, token ID, royalty vault address, and distribute royalty tokens transaction hash. """ try: nft_contract = validate_address(nft_contract) diff --git a/tests/unit/resources/test_ip_asset.py b/tests/unit/resources/test_ip_asset.py index 0f49d21..0564dcd 100644 --- a/tests/unit/resources/test_ip_asset.py +++ b/tests/unit/resources/test_ip_asset.py @@ -1821,3 +1821,53 @@ def test_throw_error_when_transaction_failed( ) ], ) + + def test_success_with_tx_options( + self, + ip_asset: IPAsset, + mock_get_ip_id, + mock_is_registered, + mock_parse_ip_registered_event, + mock_signature_related_methods, + mock_get_royalty_vault_address_by_ip_id, + mock_ip_account_impl_client, + mock_license_registry_client, + ): + royalty_shares = [ + RoyaltyShareInput(recipient=ACCOUNT_ADDRESS, percentage=60.0), + ] + tx_options = { + "gas": 1000000, + "gasPrice": 10000000000, + "nonce": 1, + "chainId": 1, + } + with ( + mock_get_ip_id(), + mock_is_registered(), + mock_parse_ip_registered_event(), + mock_signature_related_methods(), + mock_get_royalty_vault_address_by_ip_id(), + mock_ip_account_impl_client(), + mock_license_registry_client(), + ): + 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", + } + ip_asset.register_derivative_ip_and_attach_pil_terms_and_distribute_royalty_tokens( + nft_contract=ADDRESS, + token_id=3, + deriv_data=DerivativeDataInput( + parent_ip_ids=[IP_ID], + license_terms_ids=[1], + ), + royalty_shares=royalty_shares, + ip_metadata=IP_METADATA, + deadline=100000, + tx_options=tx_options, + ) + assert mock_build_and_send.call_args[1]["tx_options"] == tx_options