diff --git a/src/story_protocol_python_sdk/__init__.py b/src/story_protocol_python_sdk/__init__.py index 4953f30..ca90a9a 100644 --- a/src/story_protocol_python_sdk/__init__.py +++ b/src/story_protocol_python_sdk/__init__.py @@ -17,6 +17,7 @@ BatchMintAndRegisterIPInput, BatchMintAndRegisterIPResponse, LicenseTermsDataInput, + LinkDerivativeResponse, MintedNFT, MintNFT, RegisterAndAttachAndDistributeRoyaltyTokensResponse, @@ -77,6 +78,7 @@ "MintedNFT", "RegisterIpAssetResponse", "RegisterDerivativeIpAssetResponse", + "LinkDerivativeResponse", # 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 79558ed..c2efdb5 100644 --- a/src/story_protocol_python_sdk/resources/IPAsset.py +++ b/src/story_protocol_python_sdk/resources/IPAsset.py @@ -56,6 +56,7 @@ BatchMintAndRegisterIPInput, BatchMintAndRegisterIPResponse, LicenseTermsDataInput, + LinkDerivativeResponse, MintedNFT, MintNFT, RegisterAndAttachAndDistributeRoyaltyTokensResponse, @@ -276,6 +277,7 @@ def register( except Exception as e: raise e + @deprecated("Use link_derivative() instead.") def register_derivative( self, child_ip_id: str, @@ -343,6 +345,7 @@ def register_derivative( except Exception as e: raise ValueError(f"Failed to register derivative: {str(e)}") from e + @deprecated("Use link_derivative() instead.") def register_derivative_with_license_tokens( self, child_ip_id: str, @@ -395,6 +398,73 @@ def register_derivative_with_license_tokens( f"Failed to register derivative with license tokens: {str(e)}" ) + def link_derivative( + self, + child_ip_id: Address, + parent_ip_ids: list[Address] | None = None, + license_terms_ids: list[int] | None = None, + license_token_ids: list[int] | None = None, + max_minting_fee: int = 0, + max_rts: int = MAX_ROYALTY_TOKEN, + max_revenue_share: int = 100, + license_template: str | None = None, + tx_options: dict | None = None, + ) -> LinkDerivativeResponse: + """ + Link a derivative IP asset using parent IP's license terms or license tokens. + + Supports the following workflows: + - If `parent_ip_ids` is provided, calls `registerDerivative`(contract method) + - If `license_token_ids` is provided, calls `registerDerivativeWithLicenseTokens`(contract method) + + :param child_ip_id Address: The derivative IP ID. + :param parent_ip_ids list[Address]: [Optional] The parent IP IDs. Required if using license terms. + :param license_terms_ids list[int]: [Optional] The IDs of the license terms that the parent IP supports. Required if `parent_ip_ids` is provided. + :param license_token_ids list[int]: [Optional] The IDs of the license tokens. + :param max_minting_fee int: [Optional] The maximum minting fee that the caller is willing to pay. + if set to 0 then no limit. (default: 0) Only used with `parent_ip_ids`. + :param max_rts int: [Optional] The maximum number of royalty tokens that can be distributed + (max: 100,000,000) (default: 100,000,000) + :param max_revenue_share int: [Optional] The maximum revenue share percentage allowed. + Must be between 0 and 100. (default: 100) Only used with `parent_ip_ids`. + :param license_template str: [Optional] The license template address. + Only used with `parent_ip_ids`. + :param tx_options dict: [Optional] Transaction options. + :return `LinkDerivativeResponse`: A dictionary with the transaction hash. + """ + try: + if parent_ip_ids is not None: + if license_terms_ids is None: + raise ValueError( + "license_terms_ids is required when parent_ip_ids is provided." + ) + response = self.register_derivative( + child_ip_id=child_ip_id, + parent_ip_ids=parent_ip_ids, + license_terms_ids=license_terms_ids, + max_minting_fee=max_minting_fee, + max_rts=max_rts, + max_revenue_share=max_revenue_share, + license_template=license_template, + tx_options=tx_options, + ) + return LinkDerivativeResponse(tx_hash=response["tx_hash"]) + elif license_token_ids is not None: + response = self.register_derivative_with_license_tokens( + child_ip_id=child_ip_id, + license_token_ids=license_token_ids, + max_rts=max_rts, + tx_options=tx_options, + ) + return LinkDerivativeResponse(tx_hash=response["tx_hash"]) + else: + raise ValueError( + "either parent_ip_ids or license_token_ids must be provided." + ) + + except Exception as e: + raise ValueError(f"Failed to link derivative: {str(e)}") from e + @deprecated("Use register_ip_asset() instead.") def mint_and_register_ip_asset_with_pil_terms( self, diff --git a/src/story_protocol_python_sdk/types/resource/IPAsset.py b/src/story_protocol_python_sdk/types/resource/IPAsset.py index 37c4a53..d698a84 100644 --- a/src/story_protocol_python_sdk/types/resource/IPAsset.py +++ b/src/story_protocol_python_sdk/types/resource/IPAsset.py @@ -226,3 +226,14 @@ class RegisterDerivativeIpAssetResponse(TypedDict, total=False): token_id: int royalty_vault: Address distribute_royalty_tokens_tx_hash: HexStr + + +class LinkDerivativeResponse(TypedDict): + """ + Response structure for linking a derivative IP asset. + + Attributes: + tx_hash: The transaction hash of the link derivative transaction. + """ + + tx_hash: HexStr diff --git a/tests/integration/config/utils.py b/tests/integration/config/utils.py index 51a9502..030b233 100644 --- a/tests/integration/config/utils.py +++ b/tests/integration/config/utils.py @@ -1,6 +1,7 @@ import hashlib import hmac import os +from typing import TypedDict import base58 from dotenv import load_dotenv @@ -279,9 +280,14 @@ def setup_royalty_vault(story_client, parent_ip_id, account): return response +class ParentIpAndLicenseTerms(TypedDict): + parent_ip_id: str + license_terms_id: int + + def mint_and_approve_license_token( story_client: StoryClient, - parent_ip_and_license_terms: dict, + parent_ip_and_license_terms: ParentIpAndLicenseTerms, account: LocalAccount, ) -> list[int]: """ @@ -319,7 +325,7 @@ def mint_and_approve_license_token( def create_parent_ip_and_license_terms( story_client: StoryClient, nft_collection, account: LocalAccount -) -> dict[str, int]: +) -> ParentIpAndLicenseTerms: """Create a parent IP with license terms for testing.""" response = story_client.IPAsset.register_ip_asset( nft=MintNFT( diff --git a/tests/integration/test_integration_ip_asset.py b/tests/integration/test_integration_ip_asset.py index 6ed8b1e..a1a5110 100644 --- a/tests/integration/test_integration_ip_asset.py +++ b/tests/integration/test_integration_ip_asset.py @@ -1748,3 +1748,78 @@ def test_register_derivative_ip_asset_mint_with_license_token_ids( 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) + + +class TestLinkDerivative: + def test_link_derivative_with_license_terms( + self, + story_client: StoryClient, + nft_collection, + ): + """Link derivative using parent IP IDs and license terms IDs.""" + # Create parent IP and license terms + parent_ip_and_license_terms = create_parent_ip_and_license_terms( + story_client, nft_collection, account + ) + # Register child IP + child_response = story_client.IPAsset.register_ip_asset( + nft=MintNFT( + type="mint", + spg_nft_contract=nft_collection, + recipient=account.address, + allow_duplicates=True, + ), + ) + # Link derivative + response = story_client.IPAsset.link_derivative( + child_ip_id=child_response["ip_id"], + 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=10_000, + max_rts=10_000_000, + max_revenue_share=50, + license_template=PIL_LICENSE_TEMPLATE, + ) + + assert response is not None + assert isinstance(response, dict) + assert "tx_hash" in response + assert isinstance(response["tx_hash"], str) + assert len(response["tx_hash"]) > 0 + + def test_link_derivative_with_license_tokens( + self, + story_client: StoryClient, + nft_collection, + ): + """Link derivative using license token IDs.""" + # Create parent IP and license terms + parent_ip_and_license_terms = create_parent_ip_and_license_terms( + story_client, nft_collection, account + ) + # Mint and approve license tokens + license_token_ids = mint_and_approve_license_token( + story_client, + parent_ip_and_license_terms, + account, + ) + # Register child IP + child_response = story_client.IPAsset.register_ip_asset( + nft=MintNFT( + type="mint", + spg_nft_contract=nft_collection, + recipient=account.address, + allow_duplicates=True, + ), + ) + response = story_client.IPAsset.link_derivative( + child_ip_id=child_response["ip_id"], + license_token_ids=license_token_ids, + max_rts=80_000_000, + ) + + assert response is not None + assert isinstance(response, dict) + assert "tx_hash" in response + assert isinstance(response["tx_hash"], str) + assert len(response["tx_hash"]) > 0 diff --git a/tests/unit/resources/test_ip_asset.py b/tests/unit/resources/test_ip_asset.py index 16ca846..0a7afc7 100644 --- a/tests/unit/resources/test_ip_asset.py +++ b/tests/unit/resources/test_ip_asset.py @@ -3022,3 +3022,188 @@ def test_success_when_license_token_ids_all_optional_parameters_are_provided_for assert result["tx_hash"] == TX_HASH.hex() assert result["ip_id"] == IP_ID assert result["token_id"] == 3 + + +class TestLinkDerivative: + def test_throw_error_when_parent_ip_ids_and_license_token_ids_are_not_provided( + self, ip_asset: IPAsset + ): + with pytest.raises( + ValueError, + match="Failed to link derivative: either parent_ip_ids or license_token_ids must be provided.", + ): + ip_asset.link_derivative(child_ip_id=IP_ID) + + def test_throw_error_when_parent_ip_ids_are_provided_and_license_terms_ids_are_not( + self, ip_asset: IPAsset + ): + with pytest.raises( + ValueError, + match="Failed to link derivative: license_terms_ids is required when parent_ip_ids is provided.", + ): + ip_asset.link_derivative( + child_ip_id=IP_ID, parent_ip_ids=[IP_ID, IP_ID], license_terms_ids=None + ) + + def test_success_when_parent_ip_ids_and_license_terms_ids_are_provided( + self, + ip_asset: IPAsset, + mock_is_registered, + mock_license_registry_client, + ): + with ( + mock_is_registered(True), + patch.object( + ip_asset.licensing_module_client, + "build_registerDerivative_transaction", + return_value={"tx_hash": TX_HASH.hex()}, + ) as mock_build_register_transaction, + mock_license_registry_client(), + ): + result = ip_asset.link_derivative( + child_ip_id=IP_ID, + parent_ip_ids=[IP_ID, IP_ID], + license_terms_ids=[1, 2], + ) + + assert ( + mock_build_register_transaction.call_args[0][0] == IP_ID + ) # child_ip_id + assert mock_build_register_transaction.call_args[0][1] == [ + IP_ID, + IP_ID, + ] # parent_ip_ids + assert mock_build_register_transaction.call_args[0][2] == [ + 1, + 2, + ] # license_terms_ids + assert ( + mock_build_register_transaction.call_args[0][3] == ADDRESS + ) # license_template + assert ( + mock_build_register_transaction.call_args[0][4] == ZERO_ADDRESS + ) # royalty_context + assert ( + mock_build_register_transaction.call_args[0][5] == 0 + ) # max_minting_fee + assert ( + mock_build_register_transaction.call_args[0][6] == MAX_ROYALTY_TOKEN + ) # max_rts + assert ( + mock_build_register_transaction.call_args[0][7] == 100 * 10**6 + ) # max_revenue_share + assert result["tx_hash"] == TX_HASH.hex() + + def test_success_when_parent_ip_ids_and_license_terms_ids_and_all_optional_parameters_are_provided( + self, + ip_asset: IPAsset, + mock_is_registered, + mock_license_registry_client, + ): + license_template = "0x" + bytes(32).hex() + with ( + mock_is_registered(True), + patch.object( + ip_asset.licensing_module_client, + "build_registerDerivative_transaction", + return_value={"tx_hash": TX_HASH.hex()}, + ) as mock_build_register_transaction, + mock_license_registry_client(), + ): + ip_asset.link_derivative( + child_ip_id=IP_ID, + parent_ip_ids=[IP_ID, IP_ID], + license_terms_ids=[1, 2], + max_minting_fee=10, + max_rts=1000_000, + max_revenue_share=10, + license_template=license_template, + ) + + assert ( + mock_build_register_transaction.call_args[0][3] == license_template + ) # license_template + assert ( + mock_build_register_transaction.call_args[0][4] == ZERO_ADDRESS + ) # royalty_context + assert ( + mock_build_register_transaction.call_args[0][5] == 10 + ) # max_minting_fee + assert ( + mock_build_register_transaction.call_args[0][6] == 1000_000 + ) # max_rts + assert ( + mock_build_register_transaction.call_args[0][7] == 10 * 10**6 + ) # max_revenue_share + + def test_success_when_license_token_ids_only_are_provided( + self, + ip_asset: IPAsset, + mock_is_registered, + mock_owner_of, + ): + with ( + mock_is_registered(True), + mock_owner_of(), + patch.object( + ip_asset.licensing_module_client, + "build_registerDerivativeWithLicenseTokens_transaction", + return_value={"tx_hash": TX_HASH.hex()}, + ) as mock_build_register_transaction, + ): + result = ip_asset.link_derivative( + license_token_ids=[1, 2], + child_ip_id=IP_ID, + ) + assert ( + mock_build_register_transaction.call_args[0][0] == IP_ID + ) # child_ip_id + assert mock_build_register_transaction.call_args[0][1] == [ + 1, + 2, + ] # license_token_ids + assert ( + mock_build_register_transaction.call_args[0][2] == ZERO_ADDRESS + ) # royalty_context + assert ( + mock_build_register_transaction.call_args[0][3] == MAX_ROYALTY_TOKEN + ) # max_rts + assert result["tx_hash"] == TX_HASH.hex() + + def test_success_when_license_token_ids_and_all_optional_parameters_are_provided( + self, + ip_asset: IPAsset, + mock_is_registered, + mock_owner_of, + ): + with ( + mock_is_registered(True), + mock_owner_of(), + patch.object( + ip_asset.licensing_module_client, + "build_registerDerivativeWithLicenseTokens_transaction", + return_value={"tx_hash": TX_HASH.hex()}, + ) as mock_build_register_transaction, + ): + ip_asset.link_derivative( + license_token_ids=[1, 2], + child_ip_id=IP_ID, + max_rts=1000, + ) + assert mock_build_register_transaction.call_args[0][3] == 1000 # max_rts + + def test_throw_error_when_license_token_ids_are_not_owned_by_caller( + self, + ip_asset: IPAsset, + mock_is_registered, + mock_owner_of, + ): + with ( + mock_is_registered(True), + mock_owner_of("0x" + bytes(20).hex()), + ): + with pytest.raises( + ValueError, + match="Failed to link derivative: Failed to register derivative with license tokens: License token id 1 must be owned by the caller.", + ): + ip_asset.link_derivative(license_token_ids=[1], child_ip_id=IP_ID)