diff --git a/src/story_protocol_python_sdk/abi/DerivativeWorkflows/DerivativeWorkflows_client.py b/src/story_protocol_python_sdk/abi/DerivativeWorkflows/DerivativeWorkflows_client.py index 536ee68..5881aca 100644 --- a/src/story_protocol_python_sdk/abi/DerivativeWorkflows/DerivativeWorkflows_client.py +++ b/src/story_protocol_python_sdk/abi/DerivativeWorkflows/DerivativeWorkflows_client.py @@ -120,3 +120,44 @@ def build_registerIpAndMakeDerivative_transaction( return self.contract.functions.registerIpAndMakeDerivative( nftContract, tokenId, derivData, ipMetadata, sigMetadataAndRegister ).build_transaction(tx_params) + + def registerIpAndMakeDerivativeWithLicenseTokens( + self, + nftContract, + tokenId, + licenseTokenIds, + royaltyContext, + maxRts, + ipMetadata, + sigMetadataAndRegister, + ): + return self.contract.functions.registerIpAndMakeDerivativeWithLicenseTokens( + nftContract, + tokenId, + licenseTokenIds, + royaltyContext, + maxRts, + ipMetadata, + sigMetadataAndRegister, + ).transact() + + def build_registerIpAndMakeDerivativeWithLicenseTokens_transaction( + self, + nftContract, + tokenId, + licenseTokenIds, + royaltyContext, + maxRts, + ipMetadata, + sigMetadataAndRegister, + tx_params, + ): + return self.contract.functions.registerIpAndMakeDerivativeWithLicenseTokens( + nftContract, + tokenId, + licenseTokenIds, + royaltyContext, + maxRts, + ipMetadata, + sigMetadataAndRegister, + ).build_transaction(tx_params) diff --git a/src/story_protocol_python_sdk/resources/IPAsset.py b/src/story_protocol_python_sdk/resources/IPAsset.py index 56e48b2..394ccf8 100644 --- a/src/story_protocol_python_sdk/resources/IPAsset.py +++ b/src/story_protocol_python_sdk/resources/IPAsset.py @@ -887,6 +887,107 @@ def mint_and_register_ip_and_make_derivative_with_license_tokens( except Exception as e: raise e + def register_ip_and_make_derivative_with_license_tokens( + self, + nft_contract: str, + token_id: int, + license_token_ids: list[int], + max_rts: int = 100_000_000, + deadline: int = 1000, + ip_metadata: IPMetadataInput | None = None, + tx_options: dict | None = None, + ) -> RegistrationResponse: + """ + Register the given NFT as a derivative IP using license tokens. + + :param nft_contract str: The address of the NFT collection. + :param token_id int: The ID of the NFT. + :param license_token_ids list[int]: The IDs of the license tokens to be burned for linking the IP to parent IPs. + :param max_rts int: [Optional] The maximum number of royalty tokens that can be distributed to the external royalty policies (max: 100,000,000). (default: 100,000,000) + :param deadline int: [Optional] Signature deadline in milliseconds. (default: 1000) + :param ip_metadata IPMetadataInput: [Optional] The desired metadata for the newly registered IP. + :param tx_options dict: [Optional] Transaction options. + :return RegistrationResponse: Dictionary with the tx hash, IP ID and token ID. + """ + try: + 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." + ) + + # Validate license token IDs and ownership + validated_license_token_ids = self._validate_license_token_ids( + license_token_ids + ) + + # Validate max_rts + validate_max_rts(max_rts) + + calculated_deadline = self.sign_util.get_deadline(deadline=deadline) + + # Get permission signature for registration and derivative + signature_response = self.sign_util.get_permission_signature( + ip_id=ip_id, + deadline=calculated_deadline, + state=Web3.to_bytes(0), + permissions=[ + { + "ipId": ip_id, + "signer": self.derivative_workflows_client.contract.address, + "to": self.core_metadata_module_client.contract.address, + "permission": AccessPermission.ALLOW, + "func": get_function_signature( + self.core_metadata_module_client.contract.abi, + "setAll", + ), + }, + { + "ipId": ip_id, + "signer": self.derivative_workflows_client.contract.address, + "to": self.licensing_module_client.contract.address, + "permission": AccessPermission.ALLOW, + "func": get_function_signature( + self.licensing_module_client.contract.abi, + "registerDerivativeWithLicenseTokens", + ), + }, + ], + ) + + response = build_and_send_transaction( + self.web3, + self.account, + self.derivative_workflows_client.build_registerIpAndMakeDerivativeWithLicenseTokens_transaction, + validate_address(nft_contract), + token_id, + validated_license_token_ids, + ZERO_ADDRESS, # royaltyContext + max_rts, + IPMetadata.from_input(ip_metadata).get_validated_data(), + { + "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"]) + + return RegistrationResponse( + tx_hash=response["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 and make derivative with license tokens: {str(e)}" + ) from e + def register_pil_terms_and_attach( self, ip_id: Address, diff --git a/src/story_protocol_python_sdk/scripts/config.json b/src/story_protocol_python_sdk/scripts/config.json index 25d587f..e032bc3 100644 --- a/src/story_protocol_python_sdk/scripts/config.json +++ b/src/story_protocol_python_sdk/scripts/config.json @@ -243,7 +243,8 @@ "functions": [ "registerIpAndMakeDerivative", "mintAndRegisterIpAndMakeDerivative", - "mintAndRegisterIpAndMakeDerivativeWithLicenseTokens" + "mintAndRegisterIpAndMakeDerivativeWithLicenseTokens", + "registerIpAndMakeDerivativeWithLicenseTokens" ] } ] diff --git a/tests/integration/test_integration_ip_asset.py b/tests/integration/test_integration_ip_asset.py index 8a8190a..251a34e 100644 --- a/tests/integration/test_integration_ip_asset.py +++ b/tests/integration/test_integration_ip_asset.py @@ -166,6 +166,120 @@ def test_register_derivative_with_license_tokens( assert isinstance(response["tx_hash"], str) assert len(response["tx_hash"]) > 0 + def test_register_ip_and_make_derivative_with_license_tokens( + self, story_client: StoryClient, parent_ip_id, non_commercial_license + ): + """Test registering an NFT as IP and making it derivative with license tokens.""" + # Mint a new NFT that will be registered as derivative IP + token_id = get_token_id(MockERC721, story_client.web3, story_client.account) + + # Mint license tokens from the parent IP + license_token_response = story_client.License.mint_license_tokens( + licensor_ip_id=parent_ip_id, + license_template=PIL_LICENSE_TEMPLATE, + license_terms_id=non_commercial_license, + amount=1, + receiver=account.address, + max_minting_fee=0, + max_revenue_share=1, + ) + license_token_ids = license_token_response["license_token_ids"] + + # approve license tokens + approve( + erc20_contract_address=LicenseTokenClient( + story_client.web3 + ).contract.address, + web3=story_client.web3, + account=account, + spender_address=DerivativeWorkflowsClient( + story_client.web3 + ).contract.address, + amount=license_token_ids[0], + ) + + response = ( + story_client.IPAsset.register_ip_and_make_derivative_with_license_tokens( + nft_contract=MockERC721, + token_id=token_id, + license_token_ids=license_token_ids, + ) + ) + + assert response is not None + assert "tx_hash" in response + assert response["tx_hash"] is not None + + assert "ip_id" in response + assert response["ip_id"] is not None + + assert "token_id" in response + assert response["token_id"] == token_id + + def test_register_ip_and_make_derivative_with_license_tokens_with_metadata( + self, story_client: StoryClient, parent_ip_id, non_commercial_license + ): + """Test registering an NFT as IP and making it derivative with license tokens and metadata.""" + # Mint a new NFT that will be registered as derivative IP + token_id = get_token_id(MockERC721, story_client.web3, story_client.account) + + # Mint license tokens from the parent IP + license_token_response = story_client.License.mint_license_tokens( + licensor_ip_id=parent_ip_id, + license_template=PIL_LICENSE_TEMPLATE, + license_terms_id=non_commercial_license, + amount=1, + receiver=account.address, + max_minting_fee=0, + max_revenue_share=1, + ) + license_token_ids = license_token_response["license_token_ids"] + + # Create metadata for the derivative IP + metadata = IPMetadataInput( + ip_metadata_uri="https://ipfs.io/ipfs/derivative-test", + ip_metadata_hash=web3.to_hex(web3.keccak(text="derivative-metadata-hash")), + nft_metadata_uri="https://ipfs.io/ipfs/derivative-nft-test", + nft_metadata_hash=web3.to_hex( + web3.keccak(text="derivative-nft-metadata-hash") + ), + ) + # approve license tokens + approve( + erc20_contract_address=LicenseTokenClient( + story_client.web3 + ).contract.address, + web3=story_client.web3, + account=account, + spender_address=DerivativeWorkflowsClient( + story_client.web3 + ).contract.address, + amount=license_token_ids[0], + ) + # Test the new method with metadata + response = ( + story_client.IPAsset.register_ip_and_make_derivative_with_license_tokens( + nft_contract=MockERC721, + token_id=token_id, + license_token_ids=license_token_ids, + max_rts=10 * 10**6, + ip_metadata=metadata, + deadline=2000, + ) + ) + + # Verify response structure + assert response is not None + assert "tx_hash" in response + assert response["tx_hash"] is not None + + assert "ip_id" in response + assert response["ip_id"] is not None + + assert "token_id" in response + assert response["token_id"] is not None + assert response["token_id"] == token_id + class TestIPAssetMinting: @pytest.fixture(scope="module") diff --git a/tests/unit/resources/test_ip_asset.py b/tests/unit/resources/test_ip_asset.py index 206a3b5..a65bf1d 100644 --- a/tests/unit/resources/test_ip_asset.py +++ b/tests/unit/resources/test_ip_asset.py @@ -567,6 +567,175 @@ def _mock(owner: str = ACCOUNT_ADDRESS): return _mock +class TestRegisterIpAndMakeDerivativeWithLicenseTokens: + def test_nft_already_registered( + self, ip_asset: IPAsset, mock_get_ip_id, mock_is_registered + ): + """Test error when NFT is already registered as IP.""" + with mock_get_ip_id(), mock_is_registered(True): + with pytest.raises( + ValueError, match="The NFT with id 3 is already registered as IP." + ): + ip_asset.register_ip_and_make_derivative_with_license_tokens( + nft_contract=ADDRESS, + token_id=3, + license_token_ids=[1, 2, 3], + ) + + def test_empty_license_token_ids( + self, ip_asset: IPAsset, mock_get_ip_id, mock_is_registered + ): + """Test error when license token IDs list is empty.""" + with mock_get_ip_id(), mock_is_registered(False): + with pytest.raises(ValueError, match="License token IDs must be provided."): + ip_asset.register_ip_and_make_derivative_with_license_tokens( + nft_contract=ADDRESS, + token_id=3, + license_token_ids=[], + ) + + 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 pytest.raises( + ValueError, match="License token id 1 must be owned by the caller." + ): + ip_asset.register_ip_and_make_derivative_with_license_tokens( + nft_contract=ADDRESS, + token_id=3, + license_token_ids=[1, 2, 3], + ) + + def test_invalid_max_rts( + self, ip_asset: IPAsset, mock_get_ip_id, mock_is_registered, mock_owner_of + ): + """Test error when max_rts is invalid.""" + with mock_get_ip_id(), mock_is_registered(False), mock_owner_of(): + with pytest.raises( + ValueError, + match="The maxRts must be greater than 0 and less than 100,000,000.", + ): + ip_asset.register_ip_and_make_derivative_with_license_tokens( + nft_contract=ADDRESS, + token_id=3, + license_token_ids=[1, 2, 3], + max_rts=1000000000000000000, + ) + + def test_success_with_default_values( + self, + ip_asset: IPAsset, + mock_get_ip_id, + mock_is_registered, + mock_owner_of, + mock_parse_ip_registered_event, + mock_signature_related_methods, + 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 patch.object( + ip_asset.derivative_workflows_client, + "build_registerIpAndMakeDerivativeWithLicenseTokens_transaction", + return_value={"tx_hash": TX_HASH.hex()}, + ) as mock_build_transaction: + result = ip_asset.register_ip_and_make_derivative_with_license_tokens( + nft_contract=ADDRESS, + token_id=3, + license_token_ids=[1, 2, 3], + ) + + # Verify the method was called with correct parameters + call_args = mock_build_transaction.call_args[0] + assert call_args[0] == ADDRESS # nft_contract + assert call_args[1] == 3 # token_id + assert call_args[2] == [1, 2, 3] # license_token_ids + assert call_args[3] == ZERO_ADDRESS # royaltyContext + assert call_args[4] == 100_000_000 # max_rts (default) + assert ( + call_args[5] == IPMetadata.from_input().get_validated_data() + ) # ip_metadata + + # Verify response + assert result["tx_hash"] == TX_HASH.hex() + assert result["ip_id"] == IP_ID + assert result["token_id"] == 3 + + def test_success_with_custom_values( + self, + ip_asset: IPAsset, + mock_get_ip_id, + mock_is_registered, + mock_owner_of, + mock_parse_ip_registered_event, + mock_signature_related_methods, + 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 patch.object( + ip_asset.derivative_workflows_client, + "build_registerIpAndMakeDerivativeWithLicenseTokens_transaction", + return_value={"tx_hash": TX_HASH.hex()}, + ) as mock_build_transaction: + result = ip_asset.register_ip_and_make_derivative_with_license_tokens( + nft_contract=ADDRESS, + token_id=3, + license_token_ids=[1, 2, 3], + max_rts=50_000_000, + deadline=2000, + ip_metadata=IPMetadataInput( + ip_metadata_uri="https://example.com/metadata.json", + ip_metadata_hash=HexStr("0x1234567890abcdef"), + nft_metadata_uri="https://example.com/nft.json", + nft_metadata_hash=HexStr("0xabcdef1234567890"), + ), + ) + + # Verify the method was called with correct parameters + call_args = mock_build_transaction.call_args[0] + assert call_args[0] == ADDRESS # nft_contract + assert call_args[1] == 3 # token_id + assert call_args[2] == [1, 2, 3] # license_token_ids + assert call_args[3] == ZERO_ADDRESS # royaltyContext + assert call_args[4] == 50_000_000 # max_rts (custom) + assert ( + call_args[5] + == IPMetadata.from_input( + IPMetadataInput( + ip_metadata_uri="https://example.com/metadata.json", + ip_metadata_hash=HexStr("0x1234567890abcdef"), + nft_metadata_uri="https://example.com/nft.json", + nft_metadata_hash=HexStr("0xabcdef1234567890"), + ) + ).get_validated_data() + ) # ip_metadata + + # Verify metadata was processed correctly + metadata = call_args[5] + assert metadata["ipMetadataURI"] == "https://example.com/metadata.json" + assert metadata["nftMetadataURI"] == "https://example.com/nft.json" + + # Verify signature data + sig_data = call_args[6] + assert "signer" in sig_data + assert "deadline" in sig_data + assert "signature" in sig_data + + # Verify response + assert result["tx_hash"] == TX_HASH.hex() + assert result["ip_id"] == IP_ID + assert result["token_id"] == 3 + + class TestMintAndRegisterIpAndMakeDerivativeWithLicenseTokens: def test_throw_error_when_license_token_ids_is_not_owned_by_caller(