diff --git a/src/story_protocol_python_sdk/__init__.py b/src/story_protocol_python_sdk/__init__.py index e8276bad..d8af7a06 100644 --- a/src/story_protocol_python_sdk/__init__.py +++ b/src/story_protocol_python_sdk/__init__.py @@ -8,6 +8,7 @@ from .resources.WIP import WIP from .story_client import StoryClient from .types.common import AccessPermission +from .types.resource.IPAsset import RegistrationResponse from .utils.constants import ( DEFAULT_FUNCTION_SELECTOR, MAX_ROYALTY_TOKEN, @@ -28,9 +29,11 @@ "IPAccount", "Dispute", "WIP", + # Types "AccessPermission", "DerivativeDataInput", "IPMetadataInput", + "RegistrationResponse", # Constants "ZERO_ADDRESS", "ZERO_HASH", 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 8cc23315..dfd6d7cd 100644 --- a/src/story_protocol_python_sdk/abi/DerivativeWorkflows/DerivativeWorkflows_client.py +++ b/src/story_protocol_python_sdk/abi/DerivativeWorkflows/DerivativeWorkflows_client.py @@ -36,6 +36,26 @@ def __init__(self, web3: Web3): abi = json.load(abi_file) self.contract = self.web3.eth.contract(address=contract_address, abi=abi) + def mintAndRegisterIpAndMakeDerivative( + self, spgNftContract, derivData, ipMetadata, recipient, allowDuplicates + ): + return self.contract.functions.mintAndRegisterIpAndMakeDerivative( + spgNftContract, derivData, ipMetadata, recipient, allowDuplicates + ).transact() + + def build_mintAndRegisterIpAndMakeDerivative_transaction( + self, + spgNftContract, + derivData, + ipMetadata, + recipient, + allowDuplicates, + tx_params, + ): + return self.contract.functions.mintAndRegisterIpAndMakeDerivative( + spgNftContract, derivData, ipMetadata, recipient, allowDuplicates + ).build_transaction(tx_params) + def registerIpAndMakeDerivative( self, nftContract, tokenId, derivData, ipMetadata, sigMetadataAndRegister ): diff --git a/src/story_protocol_python_sdk/resources/IPAsset.py b/src/story_protocol_python_sdk/resources/IPAsset.py index a9cb4465..86155419 100644 --- a/src/story_protocol_python_sdk/resources/IPAsset.py +++ b/src/story_protocol_python_sdk/resources/IPAsset.py @@ -1,6 +1,6 @@ """Module for handling IP Account operations and transactions.""" -from ens.ens import HexStr +from ens.ens import Address, HexStr from web3 import Web3 from story_protocol_python_sdk.abi.AccessController.AccessController_client import ( @@ -35,6 +35,7 @@ ) 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.utils.constants import ( MAX_ROYALTY_TOKEN, ZERO_ADDRESS, @@ -49,6 +50,7 @@ from story_protocol_python_sdk.utils.license_terms import LicenseTerms from story_protocol_python_sdk.utils.sign import Sign from story_protocol_python_sdk.utils.transaction_utils import build_and_send_transaction +from story_protocol_python_sdk.utils.validation import validate_address class IPAsset: @@ -291,7 +293,7 @@ def register_derivative( return {"tx_hash": response["tx_hash"]} except Exception as e: - raise ValueError("Failed to register derivative") from e + raise ValueError(f"Failed to register derivative: {str(e)}") from e def register_derivative_with_license_tokens( self, @@ -774,6 +776,55 @@ def register_derivative_ip( except Exception as e: raise e + def mint_and_register_ip_and_make_derivative( + self, + spg_nft_contract: str, + deriv_data: DerivativeDataInput, + ip_metadata: IPMetadataInput | None = None, + recipient: Address | None = None, + allow_duplicates: bool = True, + tx_options: dict | None = None, + ) -> RegistrationResponse: + """ + Mint an NFT from a collection and register it as a derivative IP without license tokens. + + :param spg_nft_contract str: The address of the SPGNFT collection. + :param deriv_data `DerivativeDataInput`: The derivative data to be used for register derivative. + :param ip_metadata `IPMetadataInput`: [Optional] The desired metadata for the newly minted NFT and newly registered IP. + :param recipient str: [Optional] The address to receive the minted NFT. If not provided, the client's own wallet address will be used. + :param allow_duplicates bool: [Optional] Set to true to allow minting an NFT with a duplicate metadata hash. (default: True) + :param tx_options dict: [Optional] Transaction options. + :return RegistrationResponse: Dictionary with the tx hash, IP ID and token ID. + """ + + try: + validated_deriv_data = DerivativeData.from_input( + web3=self.web3, input_data=deriv_data + ).get_validated_data() + response = build_and_send_transaction( + self.web3, + self.account, + self.derivative_workflows_client.build_mintAndRegisterIpAndMakeDerivative_transaction, + validate_address(spg_nft_contract), + validated_deriv_data, + IPMetadata.from_input(ip_metadata).get_validated_data(), + ( + validate_address(recipient) + if recipient is not None + else self.account.address + ), + allow_duplicates, + tx_options=tx_options, + ) + ip_registered = self._parse_tx_ip_registered_event(response["tx_receipt"]) + return { + "tx_hash": response["tx_hash"], + "ip_id": ip_registered["ip_id"], + "token_id": ip_registered["token_id"], + } + except Exception as e: + raise e + def _validate_max_rts(self, max_rts: int): """ Validates the maximum number of royalty tokens. diff --git a/src/story_protocol_python_sdk/scripts/config.json b/src/story_protocol_python_sdk/scripts/config.json index bdc43954..89604bb6 100644 --- a/src/story_protocol_python_sdk/scripts/config.json +++ b/src/story_protocol_python_sdk/scripts/config.json @@ -228,7 +228,10 @@ { "contract_name": "DerivativeWorkflows", "contract_address": "0x9e2d496f72C547C2C535B167e06ED8729B374a4f", - "functions": ["registerIpAndMakeDerivative"] + "functions": [ + "registerIpAndMakeDerivative", + "mintAndRegisterIpAndMakeDerivative" + ] } ] } diff --git a/src/story_protocol_python_sdk/types/resource/IPAsset.py b/src/story_protocol_python_sdk/types/resource/IPAsset.py new file mode 100644 index 00000000..feb67c1c --- /dev/null +++ b/src/story_protocol_python_sdk/types/resource/IPAsset.py @@ -0,0 +1,18 @@ +from typing import Optional, TypedDict + +from ens.ens import Address, HexStr + + +class RegistrationResponse(TypedDict): + """ + Response structure for IP asset registration operations. + + Attributes: + ip_id: The IP ID of the registered IP asset + tx_hash: The transaction hash of the registration transaction + token_id: [Optional] The token ID of the registered IP asset + """ + + ip_id: Address + tx_hash: HexStr + token_id: Optional[int] diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 9ed11c26..df29ef69 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -1,8 +1,12 @@ import pytest from story_protocol_python_sdk.story_client import StoryClient +from story_protocol_python_sdk.utils.constants import ( + ROYALTY_POLICY_LAP_ADDRESS, + ZERO_ADDRESS, +) from tests.integration.config.test_config import account, account_2, web3 -from tests.integration.config.utils import get_story_client +from tests.integration.config.utils import MockERC20, get_story_client @pytest.fixture(scope="session") @@ -15,3 +19,63 @@ def story_client() -> StoryClient: def story_client_2() -> StoryClient: """Fixture to provide the secondary story client""" return get_story_client(web3, account_2) + + +@pytest.fixture(scope="module") +def nft_collection(story_client: StoryClient): + """Fixture to provide the SPG NFT collection""" + tx_data = story_client.NFTClient.create_nft_collection( + name="test-collection", + symbol="TEST", + max_supply=100, + is_public_minting=True, + mint_open=True, + contract_uri="test-uri", + mint_fee_recipient=account.address, + ) + return tx_data["nft_contract"] + + +@pytest.fixture(scope="module") +def parent_ip_and_license_terms(story_client: StoryClient, nft_collection): + """Fixture to provide the parent IP and license terms""" + response = story_client.IPAsset.mint_and_register_ip_asset_with_pil_terms( + spg_nft_contract=nft_collection, + terms=[ + { + "terms": { + "transferable": True, + "royalty_policy": ROYALTY_POLICY_LAP_ADDRESS, + "default_minting_fee": 0, + "expiration": 0, + "commercial_use": True, + "commercial_attribution": False, + "commercializer_checker": ZERO_ADDRESS, + "commercializer_checker_data": ZERO_ADDRESS, + "commercial_rev_share": 50, + "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": 0, + "hook_data": ZERO_ADDRESS, + "licensing_hook": ZERO_ADDRESS, + "commercial_rev_share": 0, + "disabled": False, + "expect_minimum_group_reward_share": 0, + "expect_group_reward_pool": ZERO_ADDRESS, + }, + } + ], + ) + return { + "parent_ip_id": response["ip_id"], + "license_terms_id": response["license_terms_ids"][0], + } diff --git a/tests/integration/test_integration_ip_asset.py b/tests/integration/test_integration_ip_asset.py index 26dd29de..6091175d 100644 --- a/tests/integration/test_integration_ip_asset.py +++ b/tests/integration/test_integration_ip_asset.py @@ -5,6 +5,7 @@ from story_protocol_python_sdk.story_client import StoryClient from story_protocol_python_sdk.utils.derivative_data import DerivativeDataInput from story_protocol_python_sdk.utils.ip_metadata import IPMetadataInput +from tests.integration.config.test_config import account_2 from .setup_for_integration import ( PIL_LICENSE_TEMPLATE, @@ -254,62 +255,6 @@ def test_mint_register_ip(self, story_client: StoryClient, nft_collection): class TestSPGNFTOperations: - @pytest.fixture(scope="module") - def nft_collection(self, story_client: StoryClient): - tx_data = story_client.NFTClient.create_nft_collection( - name="test-collection", - symbol="TEST", - max_supply=100, - is_public_minting=True, - mint_open=True, - contract_uri="test-uri", - mint_fee_recipient=account.address, - ) - return tx_data["nft_contract"] - - @pytest.fixture(scope="module") - def parent_ip_and_license_terms(self, story_client: StoryClient, nft_collection): - response = story_client.IPAsset.mint_and_register_ip_asset_with_pil_terms( - spg_nft_contract=nft_collection, - terms=[ - { - "terms": { - "transferable": True, - "royalty_policy": ROYALTY_POLICY, - "default_minting_fee": 0, - "expiration": 0, - "commercial_use": True, - "commercial_attribution": False, - "commercializer_checker": ZERO_ADDRESS, - "commercializer_checker_data": ZERO_ADDRESS, - "commercial_rev_share": 50, - "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": 0, - "hook_data": ZERO_ADDRESS, - "licensing_hook": ZERO_ADDRESS, - "commercial_rev_share": 0, - "disabled": False, - "expect_minimum_group_reward_share": 0, - "expect_group_reward_pool": ZERO_ADDRESS, - }, - } - ], - ) - - return { - "parent_ip_id": response["ip_id"], - "license_terms_id": response["license_terms_ids"][0], - } # def test_register_ip_asset_with_metadata(self, story_client, nft_collection): # token_id = mint_by_spg(nft_collection, story_client.web3, story_client.account, "test-metadata") @@ -693,3 +638,46 @@ def test_mint_with_existing_metadata_hash_no_duplicates( metadata_hash=metadata_hash, allow_duplicates=False, ) + + +class TestMintAndRegisterIpAndMakeDerivative: + def test_default_value( + self, story_client: StoryClient, nft_collection, parent_ip_and_license_terms + ): + response = story_client.IPAsset.mint_and_register_ip_and_make_derivative( + spg_nft_contract=nft_collection, + 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"]], + ), + ) + assert response is not None + assert isinstance(response["tx_hash"], str) + assert isinstance(response["ip_id"], str) + assert isinstance(response["token_id"], int) + + def test_with_custom_value( + self, story_client: StoryClient, nft_collection, parent_ip_and_license_terms + ): + response = story_client.IPAsset.mint_and_register_ip_and_make_derivative( + spg_nft_contract=nft_collection, + 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, + ), + ip_metadata=IPMetadataInput( + ip_metadata_uri="https://example.com/metadata/custom-value.json", + ip_metadata_hash=web3.keccak(text="custom-value-metadata"), + nft_metadata_uri="https://example.com/metadata/custom-value.json", + nft_metadata_hash=web3.keccak(text="custom-value-metadata"), + ), + recipient=account_2.address, + allow_duplicates=False, + ) + assert response is not None + assert isinstance(response["tx_hash"], str) + assert isinstance(response["ip_id"], str) + assert isinstance(response["token_id"], int) diff --git a/tests/unit/resources/test_ip_asset.py b/tests/unit/resources/test_ip_asset.py index fcbc0fd9..6a888aa1 100644 --- a/tests/unit/resources/test_ip_asset.py +++ b/tests/unit/resources/test_ip_asset.py @@ -1,11 +1,13 @@ from unittest.mock import patch import pytest +from ens.ens import HexStr 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 -from story_protocol_python_sdk.utils.ip_metadata import IPMetadata +from story_protocol_python_sdk.utils.ip_metadata import IPMetadata, IPMetadataInput +from tests.integration.config.utils import ZERO_ADDRESS from tests.unit.fixtures.data import ( ADDRESS, CHAIN_ID, @@ -49,30 +51,30 @@ def _mock(): return patch.object( ip_asset, "_parse_tx_ip_registered_event", - return_value={"ip_id": IP_ID, "token_id": 1}, + return_value={"ip_id": IP_ID, "token_id": 3}, ) return _mock @pytest.fixture(scope="class") -def mock_parse_tx_license_terms_attached_event(ip_asset): +def mock_get_function_signature(): def _mock(): - return patch.object( - ip_asset, - "_parse_tx_license_terms_attached_event", - return_value=[1, 2], + return patch( + "story_protocol_python_sdk.resources.IPAsset.get_function_signature", + return_value="setAll(address,string,bytes32,bytes32)", ) return _mock @pytest.fixture(scope="class") -def mock_get_function_signature(): +def mock_parse_tx_license_terms_attached_event(ip_asset): def _mock(): - return patch( - "story_protocol_python_sdk.resources.IPAsset.get_function_signature", - return_value="setAll(address,string,bytes32,bytes32)", + return patch.object( + ip_asset, + "_parse_tx_license_terms_attached_event", + return_value=[1, 2], ) return _mock @@ -335,16 +337,36 @@ def test_success( "licensing_config": LICENSING_CONFIG, } ], + ip_metadata={ + "ip_metadata_uri": "https://example.com/metadata/custom-value.json", + "ip_metadata_hash": "ip_metadata_hash", + "nft_metadata_uri": "https://example.com/metadata/custom-value.json", + "nft_metadata_hash": "nft_metadata_hash", + }, ) assert result == { "tx_hash": TX_HASH.hex(), "ip_id": IP_ID, "license_terms_ids": [1, 2], - "token_id": 1, + "token_id": 3, } class TestRegisterDerivative: + def test_child_ip_is_not_registered( + self, ip_asset: IPAsset, mock_get_ip_id, mock_is_registered + ): + with mock_get_ip_id(), mock_is_registered(False): + with pytest.raises( + ValueError, + match=f"Failed to register derivative: The child IP with id {IP_ID} is not registered", + ): + ip_asset.register_derivative( + child_ip_id=IP_ID, + parent_ip_ids=[IP_ID, IP_ID], + license_terms_ids=[1, 2], + ) + def test_default_value_when_not_provided( self, ip_asset: IPAsset, @@ -407,3 +429,114 @@ def test_call_value_when_provided( assert call_args[5] == 10 # max_minting_fee assert call_args[6] == 100 # max_rts assert call_args[3] == ADDRESS # license_template + + +class TestMintAndRegisterIpAndMakeDerivative: + def test_throw_error_when_spg_nft_contract_is_invalid( + self, ip_asset, mock_license_registry_client + ): + with mock_license_registry_client(): + with pytest.raises(ValueError, match="Invalid address: invalid."): + ip_asset.mint_and_register_ip_and_make_derivative( + spg_nft_contract="invalid", + deriv_data=DerivativeDataInput( + parent_ip_ids=[IP_ID, IP_ID], + license_terms_ids=[1, 2], + ), + ) + + def test_success_and_expect_value_when_default_values_not_provided( + self, + ip_asset: IPAsset, + mock_license_registry_client, + mock_parse_ip_registered_event, + ): + with mock_parse_ip_registered_event(), mock_license_registry_client(): + with patch.object( + ip_asset.derivative_workflows_client, + "build_mintAndRegisterIpAndMakeDerivative_transaction", + return_value={"tx_hash": TX_HASH.hex()}, + ) as mock_build_transaction: + result = ip_asset.mint_and_register_ip_and_make_derivative( + spg_nft_contract=ADDRESS, + deriv_data=DerivativeDataInput( + parent_ip_ids=[IP_ID, IP_ID], + license_terms_ids=[1, 2], + ), + ) + assert result["tx_hash"] == TX_HASH.hex() + assert result["ip_id"] == IP_ID + assert result["token_id"] == 3 + assert mock_build_transaction.call_args[0][1] == { + "parentIpIds": [IP_ID, IP_ID], + "licenseTermsIds": [1, 2], + "maxMintingFee": 0, + "maxRts": 100 * 10**6, + "maxRevenueShare": 100 * 10**6, + "royaltyContext": ZERO_ADDRESS, + "licenseTemplate": "0x1234567890123456789012345678901234567890", + } + assert mock_build_transaction.call_args[0][2] == { + "ipMetadataURI": "", + "ipMetadataHash": ZERO_HASH, + "nftMetadataURI": "", + "nftMetadataHash": ZERO_HASH, + } + assert ( + mock_build_transaction.call_args[0][3] + == "0xF60cBF0Ea1A61567F1dDaf79A6219D20d189155c" + ) # recipient + assert mock_build_transaction.call_args[0][4] # allowDuplicates + + def test_with_custom_value( + self, + ip_asset: IPAsset, + mock_license_registry_client, + mock_parse_ip_registered_event, + ): + with mock_parse_ip_registered_event(), mock_license_registry_client(): + with patch.object( + ip_asset.derivative_workflows_client, + "build_mintAndRegisterIpAndMakeDerivative_transaction", + return_value={"tx_hash": TX_HASH.hex()}, + ) as mock_build_transaction: + result = ip_asset.mint_and_register_ip_and_make_derivative( + spg_nft_contract=ADDRESS, + deriv_data=DerivativeDataInput( + parent_ip_ids=[IP_ID, IP_ID], + license_terms_ids=[1, 2], + max_minting_fee=10, + max_rts=100, + max_revenue_share=10, + license_template=ADDRESS, + ), + ip_metadata=IPMetadataInput( + ip_metadata_uri="https://example.com/metadata/custom-value.json", + ip_metadata_hash=HexStr("ip_metadata_hash"), + nft_metadata_uri="https://example.com/metadata/custom-value.json", + nft_metadata_hash=HexStr("nft_metadata_hash"), + ), + recipient=ADDRESS, + allow_duplicates=False, + ) + assert result["tx_hash"] == TX_HASH.hex() + assert result["ip_id"] == IP_ID + assert result["token_id"] == 3 + + assert mock_build_transaction.call_args[0][1] == { + "parentIpIds": [IP_ID, IP_ID], + "licenseTermsIds": [1, 2], + "maxMintingFee": 10, + "maxRts": 100, + "maxRevenueShare": 10 * 10**6, + "royaltyContext": ZERO_ADDRESS, + "licenseTemplate": ADDRESS, + } + assert mock_build_transaction.call_args[0][2] == { + "ipMetadataURI": "https://example.com/metadata/custom-value.json", + "ipMetadataHash": "ip_metadata_hash", + "nftMetadataURI": "https://example.com/metadata/custom-value.json", + "nftMetadataHash": "nft_metadata_hash", + } + assert mock_build_transaction.call_args[0][3] == ADDRESS # recipient + assert not mock_build_transaction.call_args[0][4] # allowDuplicates