From ac48eaf3315bc747daf01c8016c71a0ecbc224f0 Mon Sep 17 00:00:00 2001 From: Bonnie Date: Mon, 8 Dec 2025 10:04:22 +0800 Subject: [PATCH 1/6] feat: implement link_derivative method in IPAsset for linking derivative IP assets with enhanced response structure --- src/story_protocol_python_sdk/__init__.py | 2 + .../resources/IPAsset.py | 68 +++++++++++++++++++ .../types/resource/IPAsset.py | 11 +++ 3 files changed, 81 insertions(+) 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..e6c6017 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, @@ -395,6 +396,73 @@ def register_derivative_with_license_tokens( f"Failed to register derivative with license tokens: {str(e)}" ) + def link_derivative( + self, + child_ip_id: str, + parent_ip_ids: list[str] | 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 str: The derivative IP ID. + :param parent_ip_ids list[str]: [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 using license terms. + :param license_token_ids list[int]: [Optional] The IDs of the license tokens. Required if linking with 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 From 5fd171965368b89aa54c4f4ed7b578f129e921af Mon Sep 17 00:00:00 2001 From: Bonnie Date: Mon, 8 Dec 2025 11:32:26 +0800 Subject: [PATCH 2/6] test: add integration tests for linking derivative IP assets with license terms and tokens --- .../integration/test_integration_ip_asset.py | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/tests/integration/test_integration_ip_asset.py b/tests/integration/test_integration_ip_asset.py index 6ed8b1e..fc83ca9 100644 --- a/tests/integration/test_integration_ip_asset.py +++ b/tests/integration/test_integration_ip_asset.py @@ -1748,3 +1748,79 @@ 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: + """Test suite for the link_derivative helper method.""" + + @pytest.fixture(scope="class") + def child_ip_id(self, story_client: StoryClient, nft_collection): + """Register a child IP asset that will be linked as derivative.""" + response = story_client.IPAsset.register_ip_asset( + nft=MintNFT( + type="mint", + spg_nft_contract=nft_collection, + recipient=account.address, + allow_duplicates=True, + ), + ) + + assert isinstance(response["ip_id"], str) and response["ip_id"] + return response["ip_id"] + + def test_link_derivative_with_license_terms( + self, + story_client: StoryClient, + child_ip_id, + nft_collection, + ): + """Link derivative using parent IP IDs and license terms IDs.""" + parent_ip_and_license_terms = create_parent_ip_and_license_terms( + story_client, nft_collection, account + ) + + response = story_client.IPAsset.link_derivative( + child_ip_id=child_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 response["tx_hash"].startswith("0x") + assert len(response["tx_hash"]) > 0 + + def test_link_derivative_with_license_tokens( + self, + story_client: StoryClient, + child_ip_id, + nft_collection, + ): + """Link derivative using license token IDs.""" + parent_ip_and_license_terms = create_parent_ip_and_license_terms( + story_client, nft_collection, account + ) + license_token_ids = mint_and_approve_license_token( + story_client, + parent_ip_and_license_terms, + account, + ) + + response = story_client.IPAsset.link_derivative( + child_ip_id=child_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 response["tx_hash"].startswith("0x") + assert len(response["tx_hash"]) > 0 From 871c89c9cdaee2ab2e131ac5aef7808ba4c2b950 Mon Sep 17 00:00:00 2001 From: Bonnie Date: Mon, 8 Dec 2025 13:39:21 +0800 Subject: [PATCH 3/6] chore: mark register_derivative and register_derivative_with_license_tokens as deprecated in IPAsset, suggesting link_derivative as the alternative --- src/story_protocol_python_sdk/resources/IPAsset.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/story_protocol_python_sdk/resources/IPAsset.py b/src/story_protocol_python_sdk/resources/IPAsset.py index e6c6017..d1a0471 100644 --- a/src/story_protocol_python_sdk/resources/IPAsset.py +++ b/src/story_protocol_python_sdk/resources/IPAsset.py @@ -277,6 +277,7 @@ def register( except Exception as e: raise e + @deprecated("Use link_derivative() instead.") def register_derivative( self, child_ip_id: str, @@ -344,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, From 9a5ba06c21d08ff2351824c50e176d79f3f29c79 Mon Sep 17 00:00:00 2001 From: Bonnie Date: Mon, 8 Dec 2025 14:31:59 +0800 Subject: [PATCH 4/6] test: add the unit tests --- .../resources/IPAsset.py | 2 +- tests/unit/resources/test_ip_asset.py | 185 ++++++++++++++++++ 2 files changed, 186 insertions(+), 1 deletion(-) diff --git a/src/story_protocol_python_sdk/resources/IPAsset.py b/src/story_protocol_python_sdk/resources/IPAsset.py index d1a0471..34c7354 100644 --- a/src/story_protocol_python_sdk/resources/IPAsset.py +++ b/src/story_protocol_python_sdk/resources/IPAsset.py @@ -459,7 +459,7 @@ def link_derivative( return LinkDerivativeResponse(tx_hash=response["tx_hash"]) else: raise ValueError( - "Either parent_ip_ids or license_token_ids must be provided." + "either parent_ip_ids or license_token_ids must be provided." ) except Exception as e: 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) From 06ca833c00354ebcd0dd3af8ba4fa1084d7922a6 Mon Sep 17 00:00:00 2001 From: Bonnie Date: Mon, 8 Dec 2025 14:44:18 +0800 Subject: [PATCH 5/6] refactor: enhance the integration tests --- tests/integration/config/utils.py | 10 +++- .../integration/test_integration_ip_asset.py | 49 +++++++++---------- 2 files changed, 32 insertions(+), 27 deletions(-) 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 fc83ca9..a1a5110 100644 --- a/tests/integration/test_integration_ip_asset.py +++ b/tests/integration/test_integration_ip_asset.py @@ -1751,36 +1751,28 @@ def test_register_derivative_ip_asset_mint_with_license_token_ids( class TestLinkDerivative: - """Test suite for the link_derivative helper method.""" - - @pytest.fixture(scope="class") - def child_ip_id(self, story_client: StoryClient, nft_collection): - """Register a child IP asset that will be linked as derivative.""" - response = story_client.IPAsset.register_ip_asset( - nft=MintNFT( - type="mint", - spg_nft_contract=nft_collection, - recipient=account.address, - allow_duplicates=True, - ), - ) - - assert isinstance(response["ip_id"], str) and response["ip_id"] - return response["ip_id"] - def test_link_derivative_with_license_terms( self, story_client: StoryClient, - child_ip_id, 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_ip_id, + 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, @@ -1793,27 +1785,35 @@ def test_link_derivative_with_license_terms( assert isinstance(response, dict) assert "tx_hash" in response assert isinstance(response["tx_hash"], str) - assert response["tx_hash"].startswith("0x") assert len(response["tx_hash"]) > 0 def test_link_derivative_with_license_tokens( self, story_client: StoryClient, - child_ip_id, 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_ip_id, + child_ip_id=child_response["ip_id"], license_token_ids=license_token_ids, max_rts=80_000_000, ) @@ -1822,5 +1822,4 @@ def test_link_derivative_with_license_tokens( assert isinstance(response, dict) assert "tx_hash" in response assert isinstance(response["tx_hash"], str) - assert response["tx_hash"].startswith("0x") assert len(response["tx_hash"]) > 0 From 804a315cab20127f8eba47439a27ef7c2d1a415a Mon Sep 17 00:00:00 2001 From: Bonnie Date: Mon, 8 Dec 2025 14:55:48 +0800 Subject: [PATCH 6/6] refactor: update parameter types in link_derivative method to use Address type for child_ip_id and parent_ip_ids --- src/story_protocol_python_sdk/resources/IPAsset.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/story_protocol_python_sdk/resources/IPAsset.py b/src/story_protocol_python_sdk/resources/IPAsset.py index 34c7354..c2efdb5 100644 --- a/src/story_protocol_python_sdk/resources/IPAsset.py +++ b/src/story_protocol_python_sdk/resources/IPAsset.py @@ -400,8 +400,8 @@ def register_derivative_with_license_tokens( def link_derivative( self, - child_ip_id: str, - parent_ip_ids: list[str] | None = None, + 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, @@ -417,10 +417,10 @@ def link_derivative( - If `parent_ip_ids` is provided, calls `registerDerivative`(contract method) - If `license_token_ids` is provided, calls `registerDerivativeWithLicenseTokens`(contract method) - :param child_ip_id str: The derivative IP ID. - :param parent_ip_ids list[str]: [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 using license terms. - :param license_token_ids list[int]: [Optional] The IDs of the license tokens. Required if linking with license tokens. + :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