From 3f4ba8b3b03e61c54a65a4716a3dcde6ef8d4355 Mon Sep 17 00:00:00 2001 From: Bonnie Date: Thu, 28 Aug 2025 15:43:24 +0800 Subject: [PATCH 1/4] feat: add register_pil_terms_and_attach method to IPAsset class --- .../resources/IPAsset.py | 96 ++++++++++++++++++- .../types/resource/IPAsset.py | 13 +++ 2 files changed, 106 insertions(+), 3 deletions(-) diff --git a/src/story_protocol_python_sdk/resources/IPAsset.py b/src/story_protocol_python_sdk/resources/IPAsset.py index f6f97da..56e48b2 100644 --- a/src/story_protocol_python_sdk/resources/IPAsset.py +++ b/src/story_protocol_python_sdk/resources/IPAsset.py @@ -12,6 +12,9 @@ from story_protocol_python_sdk.abi.DerivativeWorkflows.DerivativeWorkflows_client import ( DerivativeWorkflowsClient, ) +from story_protocol_python_sdk.abi.IPAccountImpl.IPAccountImpl_client import ( + IPAccountImplClient, +) from story_protocol_python_sdk.abi.IPAssetRegistry.IPAssetRegistry_client import ( IPAssetRegistryClient, ) @@ -35,7 +38,10 @@ ) 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 RegistrationResponse +from story_protocol_python_sdk.types.resource.IPAsset import ( + RegisterPILTermsAndAttachResponse, + RegistrationResponse, +) from story_protocol_python_sdk.utils.constants import ( MAX_ROYALTY_TOKEN, ZERO_ADDRESS, @@ -881,6 +887,90 @@ def mint_and_register_ip_and_make_derivative_with_license_tokens( except Exception as e: raise e + def register_pil_terms_and_attach( + self, + ip_id: Address, + license_terms_data: list, + deadline: int | None = None, + tx_options: dict | None = None, + ) -> RegisterPILTermsAndAttachResponse: + """ + Register Programmable IP License Terms (if unregistered) and attach it to IP. + + :param ip_id Address: The IP ID. + :param license_terms_data list: The data of the license and its configuration to be attached to the IP. + :param deadline int: [Optional] Signature deadline in milliseconds. If not provided, the current time + 1000ms will be used. + :param tx_options dict: [Optional] Transaction options. + :return RegisterPILTermsAndAttachResponse: Dictionary with the tx hash and license terms IDs. + """ + try: + if not self._is_registered(ip_id): + raise ValueError(f"The IP with id {ip_id} is not registered.") + 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"] + ), + } + ) + signature_response = self.sign_util.get_permission_signature( + ip_id=ip_id, + deadline=calculated_deadline, + state=state, + permissions=[ + { + "ipId": ip_id, + "signer": self.license_attachment_workflows_client.contract.address, + "to": self.licensing_module_client.contract.address, + "permission": AccessPermission.ALLOW, + "func": get_function_signature( + self.licensing_module_client.contract.abi, + "attachLicenseTerms", + ), + }, + { + "ipId": ip_id, + "signer": self.license_attachment_workflows_client.contract.address, + "to": self.licensing_module_client.contract.address, + "permission": AccessPermission.ALLOW, + "func": get_function_signature( + self.licensing_module_client.contract.abi, + "setLicensingConfig", + ), + }, + ], + ) + response = build_and_send_transaction( + self.web3, + self.account, + self.license_attachment_workflows_client.build_registerPILTermsAndAttach_transaction, + ip_id, + license_terms, + { + "signer": self.web3.to_checksum_address(self.account.address), + "deadline": calculated_deadline, + "signature": signature_response["signature"], + }, + tx_options=tx_options, + ) + license_terms_ids = self._parse_tx_license_terms_attached_event( + response["tx_receipt"] + ) + return RegisterPILTermsAndAttachResponse( + tx_hash=response["tx_hash"], + license_terms_ids=license_terms_ids, + ) + except Exception as e: + raise e + def _validate_derivative_data(self, derivative_data: dict) -> dict: """ Validates the derivative data and returns processed internal data. @@ -1034,7 +1124,7 @@ def _parse_tx_license_term_attached_event(self, tx_receipt: dict) -> int | None: return license_terms_id return None - def _parse_tx_license_terms_attached_event(self, tx_receipt: dict) -> list | None: + def _parse_tx_license_terms_attached_event(self, tx_receipt: dict) -> list[int]: """ Parse the LicenseTermsAttached events from a transaction receipt. @@ -1052,4 +1142,4 @@ def _parse_tx_license_terms_attached_event(self, tx_receipt: dict) -> list | Non license_terms_id = int.from_bytes(data[-32:], byteorder="big") license_terms_ids.append(license_terms_id) - return license_terms_ids if license_terms_ids else None + return license_terms_ids diff --git a/src/story_protocol_python_sdk/types/resource/IPAsset.py b/src/story_protocol_python_sdk/types/resource/IPAsset.py index feb67c1..b7424f9 100644 --- a/src/story_protocol_python_sdk/types/resource/IPAsset.py +++ b/src/story_protocol_python_sdk/types/resource/IPAsset.py @@ -16,3 +16,16 @@ class RegistrationResponse(TypedDict): ip_id: Address tx_hash: HexStr token_id: Optional[int] + + +class RegisterPILTermsAndAttachResponse(TypedDict): + """ + Response structure for Programmable IP License Terms registration and attachment operations. + + Attributes: + tx_hash: The transaction hash of the registration transaction + license_terms_ids: The IDs of the registered license terms + """ + + tx_hash: HexStr + license_terms_ids: list[int] From 7595638bff4c8f94429e6ed1d75a556c26d0bbca Mon Sep 17 00:00:00 2001 From: Bonnie Date: Thu, 28 Aug 2025 15:45:28 +0800 Subject: [PATCH 2/4] Introduced integration and unit tests for the new register_pil_terms_and_attach method. --- .../integration/test_integration_ip_asset.py | 79 ++++++++++ tests/unit/fixtures/data.py | 1 - tests/unit/resources/test_ip_asset.py | 140 ++++++++++++++++++ 3 files changed, 219 insertions(+), 1 deletion(-) diff --git a/tests/integration/test_integration_ip_asset.py b/tests/integration/test_integration_ip_asset.py index 9a8bdc0..82f1900 100644 --- a/tests/integration/test_integration_ip_asset.py +++ b/tests/integration/test_integration_ip_asset.py @@ -795,3 +795,82 @@ def test_with_custom_value( 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": { + "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 diff --git a/tests/unit/fixtures/data.py b/tests/unit/fixtures/data.py index 891b731..9b3efb9 100644 --- a/tests/unit/fixtures/data.py +++ b/tests/unit/fixtures/data.py @@ -21,7 +21,6 @@ "derivative_rev_ceiling": 100, "uri": "https://example.com", "transferable": True, - "expect_minimum_group_reward_share": 10, } LICENSING_CONFIG = { diff --git a/tests/unit/resources/test_ip_asset.py b/tests/unit/resources/test_ip_asset.py index dbd594c..206a3b5 100644 --- a/tests/unit/resources/test_ip_asset.py +++ b/tests/unit/resources/test_ip_asset.py @@ -3,6 +3,9 @@ import pytest from ens.ens import HexStr +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 @@ -81,6 +84,18 @@ def _mock(): return _mock +@pytest.fixture +def mock_ip_account_impl_client(ip_asset): + def _mock(): + return patch.object( + IPAccountImplClient, + "state", + return_value=ip_asset.web3.to_bytes(), + ) + + return _mock + + class TestIPAssetRegister: def test_register_invalid_deadline_type( self, ip_asset, mock_get_ip_id, mock_is_registered @@ -679,3 +694,128 @@ def test_throw_error_when_transaction_failed( license_token_ids=[1, 2, 3], max_rts=100, ) + + +class TestRegisterPilTermsAndAttach: + + def test_throw_error_when_ip_is_not_registered( + self, + ip_asset: IPAsset, + mock_is_registered, + ): + with mock_is_registered(False): + with pytest.raises( + ValueError, + match=f"The IP with id {IP_ID} is not registered.", + ): + ip_asset.register_pil_terms_and_attach( + ip_id=IP_ID, + license_terms_data=[ + { + "terms": LICENSE_TERMS, + "licensing_config": LICENSING_CONFIG, + }, + { + "terms": { + **LICENSE_TERMS, + "commercial_rev_share": 10, + }, + "licensing_config": { + **LICENSING_CONFIG, + "commercial_rev_share": 10, + }, + }, + ], + ) + + def test_successful_registration( + self, + ip_asset: IPAsset, + mock_is_registered, + 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", + return_value={"tx_hash": TX_HASH.hex()}, + ) as mock_build_transaction: + result = ip_asset.register_pil_terms_and_attach( + ip_id=IP_ID, + license_terms_data=[ + { + "terms": LICENSE_TERMS, + "licensing_config": LICENSING_CONFIG, + }, + { + "terms": LICENSE_TERMS, + "licensing_config": LICENSING_CONFIG, + }, + ], + ) + assert mock_build_transaction.call_args[0][1][0] == { + "terms": { + "transferable": True, + "royaltyPolicy": "0x1234567890123456789012345678901234567890", + "defaultMintingFee": 10, + "expiration": 100, + "commercialUse": True, + "commercialAttribution": True, + "commercializerChecker": True, + "commercializerCheckerData": b"mock_bytes", + "commercialRevShare": 19000000, + "commercialRevCeiling": 0, + "derivativesAllowed": True, + "derivativesAttribution": True, + "derivativesApproval": True, + "derivativesReciprocal": True, + "derivativeRevCeiling": 100, + "currency": "0x1234567890123456789012345678901234567890", + "uri": "https://example.com", + }, + "licensingConfig": { + "isSet": True, + "mintingFee": 10, + "hookData": b"mock_bytes", + "licensingHook": "0x1234567890123456789012345678901234567890", + "commercialRevShare": 10000000, + "disabled": False, + "expectMinimumGroupRewardShare": 10000000, + "expectGroupRewardPool": "0x1234567890123456789012345678901234567890", + }, + } + assert result["tx_hash"] == TX_HASH.hex() + assert result["license_terms_ids"] == [1, 2] + + def test_registration_with_transaction_failed( + self, + ip_asset: IPAsset, + mock_is_registered, + 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", + side_effect=Exception("Transaction failed."), + ): + with pytest.raises(Exception, match="Transaction failed."): + ip_asset.register_pil_terms_and_attach( + ip_id=IP_ID, + license_terms_data=[ + { + "terms": LICENSE_TERMS, + "licensing_config": LICENSING_CONFIG, + }, + ], + ) From a35ef821d9a01ae52783a769d23dcb9fbd6f592f Mon Sep 17 00:00:00 2001 From: Bonnie Date: Thu, 28 Aug 2025 15:46:29 +0800 Subject: [PATCH 3/4] fix: update deposit amount in WIP integration test to a smaller value --- tests/integration/test_integration_wip.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/test_integration_wip.py b/tests/integration/test_integration_wip.py index 066be08..2a8fefa 100644 --- a/tests/integration/test_integration_wip.py +++ b/tests/integration/test_integration_wip.py @@ -16,7 +16,7 @@ class TestWIPDeposit: def test_deposit(self, story_client: StoryClient): """Test depositing IP to WIP""" - ip_amt = web3.to_wei(1, "ether") # or Web3.to_wei("0.01", 'ether') + ip_amt = web3.to_wei(0.000001, "ether") # or Web3.to_wei("0.01", 'ether') # Get balances before deposit balance_before = story_client.get_wallet_balance() From 9db503de6bc313ffd3f999037405dbfc69e627b6 Mon Sep 17 00:00:00 2001 From: Bonnie Date: Thu, 28 Aug 2025 16:39:12 +0800 Subject: [PATCH 4/4] fix: allow duplicates in mint and register IP integration test --- tests/integration/test_integration_ip_asset.py | 2 +- tests/integration/test_integration_wip.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/integration/test_integration_ip_asset.py b/tests/integration/test_integration_ip_asset.py index 82f1900..8a8190a 100644 --- a/tests/integration/test_integration_ip_asset.py +++ b/tests/integration/test_integration_ip_asset.py @@ -789,7 +789,7 @@ def test_with_custom_value( nft_metadata_hash=web3.keccak(text="custom-value-metadata"), ), recipient=account_2.address, - allow_duplicates=False, + allow_duplicates=True, ) assert response is not None assert isinstance(response["tx_hash"], str) diff --git a/tests/integration/test_integration_wip.py b/tests/integration/test_integration_wip.py index 2a8fefa..87ada03 100644 --- a/tests/integration/test_integration_wip.py +++ b/tests/integration/test_integration_wip.py @@ -16,7 +16,7 @@ class TestWIPDeposit: def test_deposit(self, story_client: StoryClient): """Test depositing IP to WIP""" - ip_amt = web3.to_wei(0.000001, "ether") # or Web3.to_wei("0.01", 'ether') + ip_amt = web3.to_wei(0.000001, "ether") # Get balances before deposit balance_before = story_client.get_wallet_balance()