From 88cac8831669e8762764102658d9c19301d6100e Mon Sep 17 00:00:00 2001 From: Bonnie Date: Wed, 15 Oct 2025 17:48:14 +0800 Subject: [PATCH 1/9] feat: implement NativeRoyaltyPolicy enum and utility function for royalty policy address resolution --- .../types/resource/Royalty.py | 26 ++++++++++++ .../utils/royalty_policy.py | 41 +++++++++++++++++++ tests/unit/utils/test_royalty_policy.py | 39 ++++++++++++++++++ 3 files changed, 106 insertions(+) create mode 100644 src/story_protocol_python_sdk/types/resource/Royalty.py create mode 100644 src/story_protocol_python_sdk/utils/royalty_policy.py create mode 100644 tests/unit/utils/test_royalty_policy.py diff --git a/src/story_protocol_python_sdk/types/resource/Royalty.py b/src/story_protocol_python_sdk/types/resource/Royalty.py new file mode 100644 index 0000000..404df5a --- /dev/null +++ b/src/story_protocol_python_sdk/types/resource/Royalty.py @@ -0,0 +1,26 @@ +from enum import IntEnum + +from ens.ens import Address + + +class NativeRoyaltyPolicy(IntEnum): + """ + Native royalty policy created by the Story team. + + For more information: + - LAP: https://docs.story.foundation/concepts/royalty-module/liquid-absolute-percentage + - LRP: https://docs.story.foundation/concepts/royalty-module/liquid-relative-percentage + + Attributes: + LAP: Liquid Absolute Percentage - defines that each parent IP Asset can choose a minimum royalty percentage that all of its downstream IP Assets in a derivative chain will share from their monetary gains as defined in the license agreement. + LRP: Liquid Relative Percentage - royalty policy defines that each parent IP Asset can choose a minimum royalty percentage that only the direct derivative IP Assets in a derivative chain will share from their monetary gains as defined in the license agreement. + """ + + LAP = 0 + LRP = 1 + + +# Type alias for royalty policy input +# Allow custom royalty policy address or use a native royalty policy enum. +# For custom royalty policy, see https://docs.story.foundation/concepts/royalty-module/external-royalty-policies +RoyaltyPolicyInput = Address | NativeRoyaltyPolicy diff --git a/src/story_protocol_python_sdk/utils/royalty_policy.py b/src/story_protocol_python_sdk/utils/royalty_policy.py new file mode 100644 index 0000000..22001f7 --- /dev/null +++ b/src/story_protocol_python_sdk/utils/royalty_policy.py @@ -0,0 +1,41 @@ +from ens.ens import Address + +from story_protocol_python_sdk.types.resource.Royalty import NativeRoyaltyPolicy +from story_protocol_python_sdk.utils.constants import ( + ROYALTY_POLICY_LAP_ADDRESS, + ROYALTY_POLICY_LRP_ADDRESS, +) +from story_protocol_python_sdk.utils.validation import validate_address + + +def royalty_policy_input_to_address( + input: Address | NativeRoyaltyPolicy | None = None, +) -> Address: + """ + Convert RoyaltyPolicyInput to an address. + + Args: + input: The royalty policy input. Can be None, a NativeRoyaltyPolicy enum value, or a custom address. + + Returns: + Address: The corresponding royalty policy address. + - If None, returns the default LAP policy address + - If a string address, validates and returns it (custom address) + - If NativeRoyaltyPolicy.LAP (0), returns the LAP policy address + - If NativeRoyaltyPolicy.LRP (1), returns the LRP policy address + + Raises: + ValueError: If the custom address is invalid + """ + if input is None: + return ROYALTY_POLICY_LAP_ADDRESS + + if isinstance(input, str): + return validate_address(input) + + if input == NativeRoyaltyPolicy.LAP: + return ROYALTY_POLICY_LAP_ADDRESS + elif input == NativeRoyaltyPolicy.LRP: + return ROYALTY_POLICY_LRP_ADDRESS + + return ROYALTY_POLICY_LAP_ADDRESS diff --git a/tests/unit/utils/test_royalty_policy.py b/tests/unit/utils/test_royalty_policy.py new file mode 100644 index 0000000..c0276bf --- /dev/null +++ b/tests/unit/utils/test_royalty_policy.py @@ -0,0 +1,39 @@ +import pytest + +from story_protocol_python_sdk.types.resource.Royalty import NativeRoyaltyPolicy +from story_protocol_python_sdk.utils.constants import ( + ROYALTY_POLICY_LAP_ADDRESS, + ROYALTY_POLICY_LRP_ADDRESS, +) +from story_protocol_python_sdk.utils.royalty_policy import ( + royalty_policy_input_to_address, +) + + +class TestRoyaltyPolicyInputToAddress: + def test_none_input_returns_lap_address(self): + """Test that None input returns LAP address""" + result = royalty_policy_input_to_address(None) + assert result == ROYALTY_POLICY_LAP_ADDRESS + + def test_lap_enum_returns_lap_address(self): + """Test that NativeRoyaltyPolicy.LAP returns LAP address""" + result = royalty_policy_input_to_address(NativeRoyaltyPolicy.LAP) + assert result == ROYALTY_POLICY_LAP_ADDRESS + + def test_lrp_enum_returns_lrp_address(self): + """Test that NativeRoyaltyPolicy.LRP returns LRP address""" + result = royalty_policy_input_to_address(NativeRoyaltyPolicy.LRP) + assert result == ROYALTY_POLICY_LRP_ADDRESS + + def test_valid_custom_address_returns_checksum_address(self): + """Test that valid custom address returns checksum format address""" + custom_address = "0x1234567890123456789012345678901234567890" + result = royalty_policy_input_to_address(custom_address) + assert result == "0x1234567890123456789012345678901234567890" + + def test_invalid_custom_address_raises_error(self): + """Test that invalid custom address raises ValueError""" + invalid_address = "invalid_address" + with pytest.raises(ValueError): + royalty_policy_input_to_address(invalid_address) From 9b3ca88165fd47cb28f30d6d740de456efa9989c Mon Sep 17 00:00:00 2001 From: Bonnie Date: Thu, 16 Oct 2025 11:26:49 +0800 Subject: [PATCH 2/9] refactor: update royalty share handling by replacing deprecated RoyaltyShare with get_royalty_shares utility function --- src/story_protocol_python_sdk/__init__.py | 2 +- .../resources/IPAsset.py | 10 +- .../types/resource/Royalty.py | 12 +++ .../utils/royalty.py | 93 +++++++++++++++++++ .../utils/royalty_policy.py | 41 -------- .../utils/royalty_shares.py | 71 -------------- .../integration/test_integration_ip_asset.py | 2 +- tests/unit/resources/test_ip_asset.py | 12 +-- ...test_royalty_shares.py => test_royalty.py} | 83 ++++++++++++----- tests/unit/utils/test_royalty_policy.py | 39 -------- 10 files changed, 175 insertions(+), 190 deletions(-) create mode 100644 src/story_protocol_python_sdk/utils/royalty.py delete mode 100644 src/story_protocol_python_sdk/utils/royalty_policy.py delete mode 100644 src/story_protocol_python_sdk/utils/royalty_shares.py rename tests/unit/utils/{test_royalty_shares.py => test_royalty.py} (80%) delete mode 100644 tests/unit/utils/test_royalty_policy.py diff --git a/src/story_protocol_python_sdk/__init__.py b/src/story_protocol_python_sdk/__init__.py index 062f1c3..d3260e3 100644 --- a/src/story_protocol_python_sdk/__init__.py +++ b/src/story_protocol_python_sdk/__init__.py @@ -18,6 +18,7 @@ RegistrationResponse, RegistrationWithRoyaltyVaultResponse, ) +from .types.resource.Royalty import RoyaltyShareInput from .utils.constants import ( DEFAULT_FUNCTION_SELECTOR, MAX_ROYALTY_TOKEN, @@ -30,7 +31,6 @@ from .utils.derivative_data import DerivativeDataInput from .utils.ip_metadata import IPMetadataInput from .utils.licensing_config_data import LicensingConfig -from .utils.royalty_shares import RoyaltyShareInput __all__ = [ "StoryClient", diff --git a/src/story_protocol_python_sdk/resources/IPAsset.py b/src/story_protocol_python_sdk/resources/IPAsset.py index bbd9509..4d6f810 100644 --- a/src/story_protocol_python_sdk/resources/IPAsset.py +++ b/src/story_protocol_python_sdk/resources/IPAsset.py @@ -49,6 +49,7 @@ RegistrationResponse, RegistrationWithRoyaltyVaultResponse, ) +from story_protocol_python_sdk.types.resource.Royalty import RoyaltyShareInput from story_protocol_python_sdk.utils.constants import ( MAX_ROYALTY_TOKEN, ZERO_ADDRESS, @@ -61,10 +62,7 @@ from story_protocol_python_sdk.utils.function_signature import get_function_signature from story_protocol_python_sdk.utils.ip_metadata import IPMetadata, IPMetadataInput from story_protocol_python_sdk.utils.license_terms import LicenseTerms -from story_protocol_python_sdk.utils.royalty_shares import ( - RoyaltyShare, - RoyaltyShareInput, -) +from story_protocol_python_sdk.utils.royalty import get_royalty_shares 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 ( @@ -1018,9 +1016,7 @@ def mint_and_register_ip_and_make_derivative_and_distribute_royalty_tokens( :return `RegistrationWithRoyaltyVaultResponse`: Dictionary with the tx hash, IP ID and token ID, royalty vault. """ try: - validated_royalty_shares_obj = RoyaltyShare.get_royalty_shares( - royalty_shares - ) + validated_royalty_shares_obj = get_royalty_shares(royalty_shares) validated_deriv_data = DerivativeData.from_input( web3=self.web3, input_data=deriv_data ).get_validated_data() diff --git a/src/story_protocol_python_sdk/types/resource/Royalty.py b/src/story_protocol_python_sdk/types/resource/Royalty.py index 404df5a..fd9c435 100644 --- a/src/story_protocol_python_sdk/types/resource/Royalty.py +++ b/src/story_protocol_python_sdk/types/resource/Royalty.py @@ -24,3 +24,15 @@ class NativeRoyaltyPolicy(IntEnum): # Allow custom royalty policy address or use a native royalty policy enum. # For custom royalty policy, see https://docs.story.foundation/concepts/royalty-module/external-royalty-policies RoyaltyPolicyInput = Address | NativeRoyaltyPolicy + + +class RoyaltyShareInput: + """Input data structure for a single royalty share. + + Attributes: + recipient: The address of the recipient. + percentage: The percentage of the total royalty share. Supports up to 6 decimal places precision. For example, a value of 10 represents 10% of max royalty shares, which is 10,000,000. + """ + + recipient: Address + percentage: float | int diff --git a/src/story_protocol_python_sdk/utils/royalty.py b/src/story_protocol_python_sdk/utils/royalty.py new file mode 100644 index 0000000..d4f015b --- /dev/null +++ b/src/story_protocol_python_sdk/utils/royalty.py @@ -0,0 +1,93 @@ +"""Module for handling royalty-related utilities including shares and policy conversions.""" + +from typing import List + +from ens.ens import Address + +from story_protocol_python_sdk.types.resource.Royalty import ( + NativeRoyaltyPolicy, + RoyaltyShareInput, +) +from story_protocol_python_sdk.utils.constants import ( + ROYALTY_POLICY_LAP_ADDRESS, + ROYALTY_POLICY_LRP_ADDRESS, +) +from story_protocol_python_sdk.utils.validation import validate_address + + +def get_royalty_shares(royalty_shares: list[RoyaltyShareInput]) -> dict: + """ + Validate and convert royalty shares. + + :param royalty_shares: List of `RoyaltyShareInput` + :return: Dictionary with validated royalty_shares and total_amount + """ + if len(royalty_shares) == 0: + raise ValueError("Royalty shares must be provided.") + + actual_total = 0 + sum_percentage = 0.0 + converted_shares: List[dict] = [] + + for share_dict in royalty_shares: + recipient = validate_address(share_dict.recipient) + percentage = share_dict.percentage + + if percentage < 0: + raise ValueError( + "The percentage of the royalty shares must be greater than or equal to 0." + ) + + if percentage > 100: + raise ValueError( + "The percentage of the royalty shares must be less than or equal to 100." + ) + + sum_percentage += percentage + if sum_percentage > 100: + raise ValueError("The sum of the royalty shares cannot exceeds 100.") + + value = int(percentage * 10**6) + actual_total += value + + converted_shares.append( + { + "recipient": recipient, + "percentage": value, + } + ) + + return {"royalty_shares": converted_shares, "total_amount": actual_total} + + +def royalty_policy_input_to_address( + input: Address | NativeRoyaltyPolicy | None = None, +) -> Address: + """ + Convert RoyaltyPolicyInput to an address. + + Args: + input: The royalty policy input. Can be None, a NativeRoyaltyPolicy enum value, or a custom address. + + Returns: + Address: The corresponding royalty policy address. + - If None, returns the default LAP policy address + - If a string address, validates and returns it (custom address) + - If NativeRoyaltyPolicy.LAP (0), returns the LAP policy address + - If NativeRoyaltyPolicy.LRP (1), returns the LRP policy address + + Raises: + ValueError: If the custom address is invalid + """ + if input is None: + return ROYALTY_POLICY_LAP_ADDRESS + + if isinstance(input, str): + return validate_address(input) + + if input == NativeRoyaltyPolicy.LAP: + return ROYALTY_POLICY_LAP_ADDRESS + elif input == NativeRoyaltyPolicy.LRP: + return ROYALTY_POLICY_LRP_ADDRESS + + return ROYALTY_POLICY_LAP_ADDRESS diff --git a/src/story_protocol_python_sdk/utils/royalty_policy.py b/src/story_protocol_python_sdk/utils/royalty_policy.py deleted file mode 100644 index 22001f7..0000000 --- a/src/story_protocol_python_sdk/utils/royalty_policy.py +++ /dev/null @@ -1,41 +0,0 @@ -from ens.ens import Address - -from story_protocol_python_sdk.types.resource.Royalty import NativeRoyaltyPolicy -from story_protocol_python_sdk.utils.constants import ( - ROYALTY_POLICY_LAP_ADDRESS, - ROYALTY_POLICY_LRP_ADDRESS, -) -from story_protocol_python_sdk.utils.validation import validate_address - - -def royalty_policy_input_to_address( - input: Address | NativeRoyaltyPolicy | None = None, -) -> Address: - """ - Convert RoyaltyPolicyInput to an address. - - Args: - input: The royalty policy input. Can be None, a NativeRoyaltyPolicy enum value, or a custom address. - - Returns: - Address: The corresponding royalty policy address. - - If None, returns the default LAP policy address - - If a string address, validates and returns it (custom address) - - If NativeRoyaltyPolicy.LAP (0), returns the LAP policy address - - If NativeRoyaltyPolicy.LRP (1), returns the LRP policy address - - Raises: - ValueError: If the custom address is invalid - """ - if input is None: - return ROYALTY_POLICY_LAP_ADDRESS - - if isinstance(input, str): - return validate_address(input) - - if input == NativeRoyaltyPolicy.LAP: - return ROYALTY_POLICY_LAP_ADDRESS - elif input == NativeRoyaltyPolicy.LRP: - return ROYALTY_POLICY_LRP_ADDRESS - - return ROYALTY_POLICY_LAP_ADDRESS diff --git a/src/story_protocol_python_sdk/utils/royalty_shares.py b/src/story_protocol_python_sdk/utils/royalty_shares.py deleted file mode 100644 index b64de92..0000000 --- a/src/story_protocol_python_sdk/utils/royalty_shares.py +++ /dev/null @@ -1,71 +0,0 @@ -"""Module for handling royalty shares data structure and validation.""" - -from dataclasses import dataclass -from typing import List - -from ens.ens import Address - -from story_protocol_python_sdk.utils.validation import validate_address - - -@dataclass -class RoyaltyShareInput: - """Input data structure for a single royalty share. - - Attributes: - recipient: The address of the recipient. - percentage: The percentage of the total royalty share. Supports up to 6 decimal places precision. For example, a value of 10 represents 10% of max royalty shares, which is 10,000,000. - """ - - recipient: Address - percentage: float | int - - -@dataclass -class RoyaltyShare: - """Validated royalty share data.""" - - @classmethod - def get_royalty_shares(cls, royalty_shares: List[RoyaltyShareInput]): - """ - Validate and convert royalty shares. - - :param royalty_shares: List of `RoyaltyShareInput` - :return: Dictionary with validated royalty_shares and total_amount - """ - if len(royalty_shares) == 0: - raise ValueError("Royalty shares must be provided.") - - actual_total = 0 - sum_percentage = 0.0 - converted_shares: List[dict] = [] - - for share_dict in royalty_shares: - recipient = validate_address(share_dict.recipient) - percentage = share_dict.percentage - - if percentage < 0: - raise ValueError( - "The percentage of the royalty shares must be greater than or equal to 0." - ) - - if percentage > 100: - raise ValueError( - "The percentage of the royalty shares must be less than or equal to 100." - ) - - sum_percentage += percentage - if sum_percentage > 100: - raise ValueError("The sum of the royalty shares cannot exceeds 100.") - - value = int(percentage * 10**6) - actual_total += value - - converted_shares.append( - { - "recipient": recipient, - "percentage": value, - } - ) - - return {"royalty_shares": converted_shares, "total_amount": actual_total} diff --git a/tests/integration/test_integration_ip_asset.py b/tests/integration/test_integration_ip_asset.py index 152c168..87f4365 100644 --- a/tests/integration/test_integration_ip_asset.py +++ b/tests/integration/test_integration_ip_asset.py @@ -7,10 +7,10 @@ LicenseTokenClient, ) from story_protocol_python_sdk.story_client import StoryClient +from story_protocol_python_sdk.types.resource.Royalty import RoyaltyShareInput from story_protocol_python_sdk.utils.constants import ROYALTY_POLICY_LAP_ADDRESS from story_protocol_python_sdk.utils.derivative_data import DerivativeDataInput from story_protocol_python_sdk.utils.ip_metadata import IPMetadataInput -from story_protocol_python_sdk.utils.royalty_shares import RoyaltyShareInput from tests.integration.config.test_config import account_2 from tests.integration.config.utils import approve diff --git a/tests/unit/resources/test_ip_asset.py b/tests/unit/resources/test_ip_asset.py index baa3986..cc4a2c1 100644 --- a/tests/unit/resources/test_ip_asset.py +++ b/tests/unit/resources/test_ip_asset.py @@ -7,13 +7,11 @@ IPAccountImplClient, ) from story_protocol_python_sdk.resources.IPAsset import IPAsset +from story_protocol_python_sdk.types.resource.Royalty import RoyaltyShareInput 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, IPMetadataInput -from story_protocol_python_sdk.utils.royalty_shares import ( - RoyaltyShare, - RoyaltyShareInput, -) +from story_protocol_python_sdk.utils.royalty import get_royalty_shares from tests.integration.config.utils import ZERO_ADDRESS from tests.unit.fixtures.data import ( ACCOUNT_ADDRESS, @@ -1060,8 +1058,7 @@ def test_success_with_default_values( called_args = mock_build_transaction.call_args[0] assert called_args[2] == IPMetadata.from_input().get_validated_data() assert ( - called_args[4] - == RoyaltyShare.get_royalty_shares(royalty_shares)["royalty_shares"] + called_args[4] == get_royalty_shares(royalty_shares)["royalty_shares"] ) assert called_args[5] is True @@ -1155,8 +1152,7 @@ def test_success_with_custom_values( == IPMetadata.from_input(ip_metadata).get_validated_data() ) assert ( - called_args[4] - == RoyaltyShare.get_royalty_shares(royalty_shares)["royalty_shares"] + called_args[4] == get_royalty_shares(royalty_shares)["royalty_shares"] ) assert called_args[5] is False diff --git a/tests/unit/utils/test_royalty_shares.py b/tests/unit/utils/test_royalty.py similarity index 80% rename from tests/unit/utils/test_royalty_shares.py rename to tests/unit/utils/test_royalty.py index 332c94a..d012a06 100644 --- a/tests/unit/utils/test_royalty_shares.py +++ b/tests/unit/utils/test_royalty.py @@ -1,15 +1,54 @@ -"""Tests for royalty_shares module.""" +"""Tests for royalty module.""" import pytest -from story_protocol_python_sdk.utils.royalty_shares import ( - RoyaltyShare, +from story_protocol_python_sdk.types.resource.Royalty import ( + NativeRoyaltyPolicy, RoyaltyShareInput, ) +from story_protocol_python_sdk.utils.constants import ( + ROYALTY_POLICY_LAP_ADDRESS, + ROYALTY_POLICY_LRP_ADDRESS, +) +from story_protocol_python_sdk.utils.royalty import ( + get_royalty_shares, + royalty_policy_input_to_address, +) + + +class TestRoyaltyPolicyInputToAddress: + """Test royalty_policy_input_to_address function.""" + + def test_none_input_returns_lap_address(self): + """Test that None input returns LAP address""" + result = royalty_policy_input_to_address(None) + assert result == ROYALTY_POLICY_LAP_ADDRESS + + def test_lap_enum_returns_lap_address(self): + """Test that NativeRoyaltyPolicy.LAP returns LAP address""" + result = royalty_policy_input_to_address(NativeRoyaltyPolicy.LAP) + assert result == ROYALTY_POLICY_LAP_ADDRESS + + def test_lrp_enum_returns_lrp_address(self): + """Test that NativeRoyaltyPolicy.LRP returns LRP address""" + result = royalty_policy_input_to_address(NativeRoyaltyPolicy.LRP) + assert result == ROYALTY_POLICY_LRP_ADDRESS + + def test_valid_custom_address_returns_checksum_address(self): + """Test that valid custom address returns checksum format address""" + custom_address = "0x1234567890123456789012345678901234567890" + result = royalty_policy_input_to_address(custom_address) + assert result == "0x1234567890123456789012345678901234567890" + + def test_invalid_custom_address_raises_error(self): + """Test that invalid custom address raises ValueError""" + invalid_address = "invalid_address" + with pytest.raises(ValueError): + royalty_policy_input_to_address(invalid_address) -class TestRoyaltyShareGetRoyaltyShares: - """Test RoyaltyShare.get_royalty_shares method.""" +class TestGetRoyaltyShares: + """Test get_royalty_shares function.""" def test_get_royalty_shares_success(self): """Test successful processing of valid royalty shares.""" @@ -22,7 +61,7 @@ def test_get_royalty_shares_success(self): ), ] - result = RoyaltyShare.get_royalty_shares(shares) + result = get_royalty_shares(shares) expected_shares = [ { @@ -49,7 +88,7 @@ def test_get_royalty_shares_with_integer_percentages(self): ), ] - result = RoyaltyShare.get_royalty_shares(shares) + result = get_royalty_shares(shares) expected_shares = [ { "recipient": "0x1234567890123456789012345678901234567890", @@ -76,7 +115,7 @@ def test_get_royalty_shares_precision_handling_6_decimals(self): ), ] - result = RoyaltyShare.get_royalty_shares(shares) + result = get_royalty_shares(shares) # 33.333333 * 10^6 = 33333333 # 66.666667 * 10^6 = 66666667 @@ -107,7 +146,7 @@ def test_get_royalty_shares_precision_loss_more_than_6_decimals(self): ), ] - result = RoyaltyShare.get_royalty_shares(shares) + result = get_royalty_shares(shares) # Due to floating point precision and int() truncation: # 33.3333333333 * 10^6 = 33333333.3333, int() = 33333333 @@ -131,7 +170,7 @@ def test_get_royalty_shares_very_small_percentages(self): ), ] - result = RoyaltyShare.get_royalty_shares(shares) + result = get_royalty_shares(shares) # 0.000001 * 10^6 = 1 # 99.999999 * 10^6 = 99999999 @@ -147,7 +186,7 @@ def test_get_royalty_shares_boundary_case_exactly_100_percent(self): ) ] - result = RoyaltyShare.get_royalty_shares(shares) + result = get_royalty_shares(shares) assert result["royalty_shares"][0]["percentage"] == 100_000_000 assert result["total_amount"] == 100_000_000 @@ -161,7 +200,7 @@ def test_get_royalty_shares_boundary_case_minimum_percentage(self): ) ] - result = RoyaltyShare.get_royalty_shares(shares) + result = get_royalty_shares(shares) assert result["royalty_shares"][0]["percentage"] == 1 assert result["total_amount"] == 1 @@ -169,7 +208,7 @@ def test_get_royalty_shares_boundary_case_minimum_percentage(self): def test_get_royalty_shares_empty_list_error(self): """Test error when providing empty royalty shares list.""" with pytest.raises(ValueError, match="Royalty shares must be provided."): - RoyaltyShare.get_royalty_shares([]) + get_royalty_shares([]) def test_get_royalty_shares_zero_percentage(self): """Test error when percentage is zero.""" @@ -179,7 +218,7 @@ def test_get_royalty_shares_zero_percentage(self): ) ] - result = RoyaltyShare.get_royalty_shares(shares) + result = get_royalty_shares(shares) assert result["royalty_shares"][0]["percentage"] == 0 assert result["total_amount"] == 0 @@ -196,7 +235,7 @@ def test_get_royalty_shares_negative_percentage_error(self): ValueError, match="he percentage of the royalty shares must be greater than or equal to 0.", ): - RoyaltyShare.get_royalty_shares(shares) + get_royalty_shares(shares) def test_get_royalty_shares_percentage_100(self): """Test when percentage is 100.""" @@ -206,7 +245,7 @@ def test_get_royalty_shares_percentage_100(self): ) ] - result = RoyaltyShare.get_royalty_shares(shares) + result = get_royalty_shares(shares) assert result["royalty_shares"][0]["percentage"] == 100_000_000 assert result["total_amount"] == 100_000_000 @@ -223,14 +262,14 @@ def test_get_royalty_shares_percentage_over_100(self): ValueError, match="The percentage of the royalty shares must be less than or equal to 100.", ): - RoyaltyShare.get_royalty_shares(shares) + get_royalty_shares(shares) def test_get_royalty_shares_invalid_address_error(self): """Test error when address is invalid.""" shares = [RoyaltyShareInput(recipient="invalid_address", percentage=50)] with pytest.raises(ValueError, match="Invalid address"): - RoyaltyShare.get_royalty_shares(shares) + get_royalty_shares(shares) def test_get_royalty_shares_cumulative_precision_boundary(self): """Test cumulative precision at the boundary of 100%.""" @@ -252,7 +291,7 @@ def test_get_royalty_shares_cumulative_precision_boundary(self): ] # This should work because 33.333333 + 33.333333 + 33.333334 = 100.0 - result = RoyaltyShare.get_royalty_shares(shares) + result = get_royalty_shares(shares) assert len(result["royalty_shares"]) == 3 assert result["total_amount"] == 100_000_000 @@ -274,7 +313,7 @@ def test_get_royalty_shares_precision_edge_case_just_over_100(self): with pytest.raises( ValueError, match="The sum of the royalty shares cannot exceeds 100." ): - RoyaltyShare.get_royalty_shares(shares) + get_royalty_shares(shares) def test_get_royalty_shares_single_recipient_multiple_entries(self): """Test multiple entries for the same recipient.""" @@ -290,7 +329,7 @@ def test_get_royalty_shares_single_recipient_multiple_entries(self): ), ] - result = RoyaltyShare.get_royalty_shares(shares) + result = get_royalty_shares(shares) # Should treat each entry separately, not merge them assert len(result["royalty_shares"]) == 3 @@ -310,7 +349,7 @@ def test_get_royalty_shares_mixed_data_types(self): ), # float ] - result = RoyaltyShare.get_royalty_shares(shares) + result = get_royalty_shares(shares) assert result["royalty_shares"][0]["percentage"] == 25_000_000 assert result["royalty_shares"][1]["percentage"] == 75_000_000 diff --git a/tests/unit/utils/test_royalty_policy.py b/tests/unit/utils/test_royalty_policy.py deleted file mode 100644 index c0276bf..0000000 --- a/tests/unit/utils/test_royalty_policy.py +++ /dev/null @@ -1,39 +0,0 @@ -import pytest - -from story_protocol_python_sdk.types.resource.Royalty import NativeRoyaltyPolicy -from story_protocol_python_sdk.utils.constants import ( - ROYALTY_POLICY_LAP_ADDRESS, - ROYALTY_POLICY_LRP_ADDRESS, -) -from story_protocol_python_sdk.utils.royalty_policy import ( - royalty_policy_input_to_address, -) - - -class TestRoyaltyPolicyInputToAddress: - def test_none_input_returns_lap_address(self): - """Test that None input returns LAP address""" - result = royalty_policy_input_to_address(None) - assert result == ROYALTY_POLICY_LAP_ADDRESS - - def test_lap_enum_returns_lap_address(self): - """Test that NativeRoyaltyPolicy.LAP returns LAP address""" - result = royalty_policy_input_to_address(NativeRoyaltyPolicy.LAP) - assert result == ROYALTY_POLICY_LAP_ADDRESS - - def test_lrp_enum_returns_lrp_address(self): - """Test that NativeRoyaltyPolicy.LRP returns LRP address""" - result = royalty_policy_input_to_address(NativeRoyaltyPolicy.LRP) - assert result == ROYALTY_POLICY_LRP_ADDRESS - - def test_valid_custom_address_returns_checksum_address(self): - """Test that valid custom address returns checksum format address""" - custom_address = "0x1234567890123456789012345678901234567890" - result = royalty_policy_input_to_address(custom_address) - assert result == "0x1234567890123456789012345678901234567890" - - def test_invalid_custom_address_raises_error(self): - """Test that invalid custom address raises ValueError""" - invalid_address = "invalid_address" - with pytest.raises(ValueError): - royalty_policy_input_to_address(invalid_address) From ddd889b5c91c57fd007fdf16eeedf60649666650 Mon Sep 17 00:00:00 2001 From: Bonnie Date: Thu, 16 Oct 2025 13:41:11 +0800 Subject: [PATCH 3/9] refactor: streamline license terms validation in IPAsset class by introducing _validate_license_terms_data method --- .../resources/IPAsset.py | 60 ++++++++----------- .../types/resource/Royalty.py | 2 + 2 files changed, 26 insertions(+), 36 deletions(-) diff --git a/src/story_protocol_python_sdk/resources/IPAsset.py b/src/story_protocol_python_sdk/resources/IPAsset.py index 4d6f810..b6a01eb 100644 --- a/src/story_protocol_python_sdk/resources/IPAsset.py +++ b/src/story_protocol_python_sdk/resources/IPAsset.py @@ -431,18 +431,7 @@ def mint_and_register_ip_asset_with_pil_terms( raise ValueError( f"The NFT contract address {spg_nft_contract} is not valid." ) - license_terms = [] - for term in terms: - license_terms.append( - { - "terms": self.license_terms_util.validate_license_terms( - term["terms"] - ), - "licensingConfig": self.license_terms_util.validate_licensing_config( - term["licensing_config"] - ), - } - ) + license_terms = self._validate_license_terms_data(terms) metadata = { "ipMetadataURI": "", @@ -618,18 +607,7 @@ def register_ip_and_attach_pil_terms( raise ValueError( f"The NFT with id {token_id} is already registered as IP." ) - 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"] - ), - } - ) + license_terms = self._validate_license_terms_data(license_terms_data) calculated_deadline = self.sign_util.get_deadline(deadline=deadline) @@ -1073,18 +1051,7 @@ def register_pil_terms_and_attach( 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"] - ), - } - ) + license_terms = self._validate_license_terms_data(license_terms_data) signature_response = self.sign_util.get_permission_signature( ip_id=ip_id, deadline=calculated_deadline, @@ -1341,3 +1308,24 @@ def _validate_recipient(self, recipient: Address | None) -> Address: if recipient is None: return self.account.address return validate_address(recipient) + + def _validate_license_terms_data(self, license_terms_data: list) -> list: + """ + Validate the license terms data. + + :param license_terms_data list: The license terms data to validate. + :return list: The validated license terms data. + """ + validated_license_terms_data = [] + for term in license_terms_data: + validated_license_terms_data.append( + { + "terms": self.license_terms_util.validate_license_terms( + term["terms"] + ), + "licensingConfig": self.license_terms_util.validate_licensing_config( + term["licensing_config"] + ), + } + ) + return validated_license_terms_data diff --git a/src/story_protocol_python_sdk/types/resource/Royalty.py b/src/story_protocol_python_sdk/types/resource/Royalty.py index fd9c435..57740b2 100644 --- a/src/story_protocol_python_sdk/types/resource/Royalty.py +++ b/src/story_protocol_python_sdk/types/resource/Royalty.py @@ -1,3 +1,4 @@ +from dataclasses import dataclass from enum import IntEnum from ens.ens import Address @@ -26,6 +27,7 @@ class NativeRoyaltyPolicy(IntEnum): RoyaltyPolicyInput = Address | NativeRoyaltyPolicy +@dataclass class RoyaltyShareInput: """Input data structure for a single royalty share. From 02a32792e1dbb3a88a381672defbf4e5c95e92b1 Mon Sep 17 00:00:00 2001 From: Bonnie Date: Thu, 16 Oct 2025 14:58:40 +0800 Subject: [PATCH 4/9] feat: implement mint_and_register_ip_and_attach_pil_terms_and_distribute_royalty_tokens method in IPAsset class --- src/story_protocol_python_sdk/__init__.py | 6 ++ .../resources/IPAsset.py | 81 +++++++++++++++++-- .../types/resource/IPAsset.py | 33 ++++++++ .../types/resource/License.py | 52 ++++++++++++ .../integration/test_integration_ip_asset.py | 81 +++++++++++++++++-- 5 files changed, 241 insertions(+), 12 deletions(-) create mode 100644 src/story_protocol_python_sdk/types/resource/License.py diff --git a/src/story_protocol_python_sdk/__init__.py b/src/story_protocol_python_sdk/__init__.py index d3260e3..c7dd0a5 100644 --- a/src/story_protocol_python_sdk/__init__.py +++ b/src/story_protocol_python_sdk/__init__.py @@ -14,10 +14,13 @@ CollectRoyaltiesResponse, ) from .types.resource.IPAsset import ( + LicenseTermsDataInput, RegisterPILTermsAndAttachResponse, RegistrationResponse, + RegistrationWithRoyaltyVaultAndLicenseTermsResponse, RegistrationWithRoyaltyVaultResponse, ) +from .types.resource.License import LicenseTermsInput from .types.resource.Royalty import RoyaltyShareInput from .utils.constants import ( DEFAULT_FUNCTION_SELECTOR, @@ -46,12 +49,15 @@ "IPMetadataInput", "RegistrationResponse", "RegistrationWithRoyaltyVaultResponse", + "RegistrationWithRoyaltyVaultAndLicenseTermsResponse", + "LicenseTermsDataInput", "ClaimRewardsResponse", "ClaimReward", "CollectRoyaltiesResponse", "LicensingConfig", "RegisterPILTermsAndAttachResponse", "RoyaltyShareInput", + "LicenseTermsInput", # 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 b6a01eb..9b93a1f 100644 --- a/src/story_protocol_python_sdk/resources/IPAsset.py +++ b/src/story_protocol_python_sdk/resources/IPAsset.py @@ -1,5 +1,7 @@ """Module for handling IP Account operations and transactions.""" +from dataclasses import asdict + from ens.ens import Address, HexStr from web3 import Web3 @@ -45,8 +47,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 ( + LicenseTermsDataInput, RegisterPILTermsAndAttachResponse, RegistrationResponse, + RegistrationWithRoyaltyVaultAndLicenseTermsResponse, RegistrationWithRoyaltyVaultResponse, ) from story_protocol_python_sdk.types.resource.Royalty import RoyaltyShareInput @@ -971,6 +975,68 @@ def register_ip_and_make_derivative_with_license_tokens( f"Failed to register IP and make derivative with license tokens: {str(e)}" ) from e + def mint_and_register_ip_and_attach_pil_terms_and_distribute_royalty_tokens( + self, + spg_nft_contract: Address, + license_terms_data: list[LicenseTermsDataInput], + royalty_shares: list[RoyaltyShareInput], + ip_metadata: IPMetadataInput | None = None, + recipient: Address | None = None, + allow_duplicates: bool = True, + tx_options: dict | None = None, + ) -> RegistrationWithRoyaltyVaultAndLicenseTermsResponse: + """ + Mint an NFT and register the IP, attach PIL terms, and distribute royalty tokens. + + :param spg_nft_contract Address: The address of the SPGNFT collection. + :param license_terms_data `list[LicenseTermsDataInput]`: The PIL terms and licensing configuration data to be attached to the IP. + :param royalty_shares `list[RoyaltyShareInput]`: The royalty shares to be distributed. + :param ip_metadata `IPMetadataInput`: [Optional] The desired metadata for the newly minted NFT and newly registered IP. + :param recipient Address: [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 false to prevent minting an NFT with a duplicate metadata hash. (default: True) + :param tx_options dict: [Optional] Transaction options. + :return `RegistrationWithRoyaltyVaultAndLicenseTermsResponse`: Response with tx hash, IP ID, token ID, license terms IDs, and royalty vault address. + """ + try: + validated_royalty_shares = get_royalty_shares(royalty_shares)[ + "royalty_shares" + ] + license_terms = self._validate_license_terms_data(license_terms_data) + + response = build_and_send_transaction( + self.web3, + self.account, + self.royalty_token_distribution_workflows_client.build_mintAndRegisterIpAndAttachPILTermsAndDistributeRoyaltyTokens_transaction, + validate_address(spg_nft_contract), + self._validate_recipient(recipient), + IPMetadata.from_input(ip_metadata).get_validated_data(), + license_terms, + validated_royalty_shares, + allow_duplicates, + tx_options=tx_options, + ) + + ip_registered = self._parse_tx_ip_registered_event(response["tx_receipt"]) + license_terms_ids = self._parse_tx_license_terms_attached_event( + response["tx_receipt"] + ) + royalty_vault = self.get_royalty_vault_address_by_ip_id( + response["tx_receipt"], + ip_registered["ip_id"], + ) + + return RegistrationWithRoyaltyVaultAndLicenseTermsResponse( + tx_hash=response["tx_hash"], + ip_id=ip_registered["ip_id"], + token_id=ip_registered["token_id"], + license_terms_ids=license_terms_ids, + royalty_vault=royalty_vault, + ) + except Exception as e: + raise ValueError( + f"Failed to mint, register IP, attach PIL terms and distribute royalty tokens: {str(e)}" + ) from e + def mint_and_register_ip_and_make_derivative_and_distribute_royalty_tokens( self, spg_nft_contract: Address, @@ -1309,22 +1375,25 @@ def _validate_recipient(self, recipient: Address | None) -> Address: return self.account.address return validate_address(recipient) - def _validate_license_terms_data(self, license_terms_data: list) -> list: + def _validate_license_terms_data( + self, license_terms_data: list[LicenseTermsDataInput] + ) -> list: """ Validate the license terms data. - :param license_terms_data list: The license terms data to validate. + :param license_terms_data `list[LicenseTermsDataInput]`: The license terms data to validate. :return list: The validated license terms data. """ + validated_license_terms_data = [] for term in license_terms_data: + # Convert dataclass to dict for validation + terms_dict = asdict(term.terms) validated_license_terms_data.append( { - "terms": self.license_terms_util.validate_license_terms( - term["terms"] - ), + "terms": self.license_terms_util.validate_license_terms(terms_dict), "licensingConfig": self.license_terms_util.validate_licensing_config( - term["licensing_config"] + term.licensing_config ), } ) diff --git a/src/story_protocol_python_sdk/types/resource/IPAsset.py b/src/story_protocol_python_sdk/types/resource/IPAsset.py index 5e0f18a..a8133c7 100644 --- a/src/story_protocol_python_sdk/types/resource/IPAsset.py +++ b/src/story_protocol_python_sdk/types/resource/IPAsset.py @@ -1,7 +1,11 @@ +from dataclasses import dataclass from typing import TypedDict from ens.ens import Address, HexStr +from story_protocol_python_sdk.types.resource.License import LicenseTermsInput +from story_protocol_python_sdk.utils.licensing_config_data import LicensingConfig + class RegistrationResponse(TypedDict): """ @@ -31,6 +35,21 @@ class RegistrationWithRoyaltyVaultResponse(RegistrationResponse): royalty_vault: Address +class RegistrationWithRoyaltyVaultAndLicenseTermsResponse( + RegistrationWithRoyaltyVaultResponse +): + """ + Response structure for IP asset registration operations with royalty vault and license terms. + + Extends `RegistrationWithRoyaltyVaultResponse` with license terms information. + + Attributes: + license_terms_ids: The IDs of the license terms attached to the IP asset + """ + + license_terms_ids: list[int] + + class RegisterPILTermsAndAttachResponse(TypedDict): """ Response structure for Programmable IP License Terms registration and attachment operations. @@ -42,3 +61,17 @@ class RegisterPILTermsAndAttachResponse(TypedDict): tx_hash: HexStr license_terms_ids: list[int] + + +@dataclass +class LicenseTermsDataInput: + """ + Data structure for license terms. + + Attributes: + terms: The terms of the license. + licensing_config: The licensing configuration of the license. + """ + + terms: LicenseTermsInput + licensing_config: LicensingConfig diff --git a/src/story_protocol_python_sdk/types/resource/License.py b/src/story_protocol_python_sdk/types/resource/License.py new file mode 100644 index 0000000..8de050f --- /dev/null +++ b/src/story_protocol_python_sdk/types/resource/License.py @@ -0,0 +1,52 @@ +from dataclasses import dataclass + +from ens.ens import Address, HexStr + +from story_protocol_python_sdk.types.resource.Royalty import RoyaltyPolicyInput + + +@dataclass +class LicenseTermsInput: + """ + This structure defines the terms for a Programmable IP License (PIL). + These terms can be attached to IP Assets. + + For more information, see https://docs.story.foundation/concepts/programmable-ip-license/pil-terms + + Attributes: + transferable: Indicates whether the license is transferable or not. + royalty_policy: The address of the royalty policy contract which required to StoryProtocol in advance. + default_minting_fee: The default minting fee to be paid when minting a license. + expiration: The expiration period of the license. + commercial_use: Indicates whether the work can be used commercially or not. + commercial_attribution: Whether attribution is required when reproducing the work commercially or not. + commercializer_checker: Commercializers that are allowed to commercially exploit the work. If zero address, then no restrictions is enforced. + commercializer_checker_data: The data to be passed to the commercializer checker contract. + commercial_rev_share: Percentage of revenue that must be shared with the licensor. Must be between 0 and 100 (where 100% represents 100_000_000). + commercial_rev_ceiling: The maximum revenue that can be generated from the commercial use of the work. + derivatives_allowed: Indicates whether the licensee can create derivatives of his work or not. + derivatives_attribution: Indicates whether attribution is required for derivatives of the work or not. + derivatives_approval: Indicates whether the licensor must approve derivatives of the work before they can be linked to the licensor IP ID or not. + derivatives_reciprocal: Indicates whether the licensee must license derivatives of the work under the same terms or not. + derivative_rev_ceiling: The maximum revenue that can be generated from the derivative use of the work. + currency: The ERC20 token to be used to pay the minting fee. The token must be registered in story protocol. + uri: The URI of the license terms, which can be used to fetch the offchain license terms. + """ + + transferable: bool + royalty_policy: RoyaltyPolicyInput + default_minting_fee: int + expiration: int + commercial_use: bool + commercial_attribution: bool + commercializer_checker: Address + commercializer_checker_data: Address | HexStr + commercial_rev_share: int + commercial_rev_ceiling: int + derivatives_allowed: bool + derivatives_attribution: bool + derivatives_approval: bool + derivatives_reciprocal: bool + derivative_rev_ceiling: int + currency: Address + uri: str diff --git a/tests/integration/test_integration_ip_asset.py b/tests/integration/test_integration_ip_asset.py index 87f4365..2e9bea2 100644 --- a/tests/integration/test_integration_ip_asset.py +++ b/tests/integration/test_integration_ip_asset.py @@ -1,16 +1,22 @@ import pytest +from story_protocol_python_sdk import ( + ROYALTY_POLICY_LAP_ADDRESS, + ZERO_ADDRESS, + ZERO_HASH, + DerivativeDataInput, + IPMetadataInput, + LicenseTermsDataInput, + LicenseTermsInput, + RoyaltyShareInput, + StoryClient, +) from story_protocol_python_sdk.abi.DerivativeWorkflows.DerivativeWorkflows_client import ( DerivativeWorkflowsClient, ) from story_protocol_python_sdk.abi.LicenseToken.LicenseToken_client import ( LicenseTokenClient, ) -from story_protocol_python_sdk.story_client import StoryClient -from story_protocol_python_sdk.types.resource.Royalty import RoyaltyShareInput -from story_protocol_python_sdk.utils.constants import ROYALTY_POLICY_LAP_ADDRESS -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 tests.integration.config.utils import approve @@ -18,7 +24,6 @@ PIL_LICENSE_TEMPLATE, ROYALTY_POLICY, WIP_TOKEN_ADDRESS, - ZERO_ADDRESS, MockERC20, MockERC721, account, @@ -549,6 +554,70 @@ def test_register_ip_and_attach_pil_terms( and result["license_terms_ids"] ) + def test_mint_and_register_ip_and_attach_pil_terms_and_distribute_royalty_tokens( + self, story_client: StoryClient, nft_collection + ): + """Test minting NFT, registering IP, attaching PIL terms and distributing royalty tokens with all optional parameters""" + license_terms_data = [ + LicenseTermsDataInput( + terms=LicenseTermsInput( + transferable=True, + royalty_policy=ROYALTY_POLICY, + default_minting_fee=10000, + expiration=1000, + commercial_use=True, + commercial_attribution=False, + commercializer_checker=ZERO_ADDRESS, + commercializer_checker_data=ZERO_HASH, + 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=WIP_TOKEN_ADDRESS, + uri="test case with custom values", + ), + licensing_config={ + "is_set": True, + "minting_fee": 10000, + "licensing_hook": ZERO_ADDRESS, + "hook_data": ZERO_HASH, + "commercial_rev_share": 10, + "disabled": False, + "expect_minimum_group_reward_share": 0, + "expect_group_reward_pool": ZERO_ADDRESS, + }, + ) + ] + royalty_shares = [ + RoyaltyShareInput(recipient=account_2.address, percentage=50.0) + ] + + response = story_client.IPAsset.mint_and_register_ip_and_attach_pil_terms_and_distribute_royalty_tokens( + spg_nft_contract=nft_collection, + license_terms_data=license_terms_data, + royalty_shares=royalty_shares, + 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-nft.json", + nft_metadata_hash=web3.keccak(text="custom-value-nft-metadata"), + ), + recipient=account_2.address, + allow_duplicates=False, + ) + + 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) + assert ( + isinstance(response["license_terms_ids"], list) + and len(response["license_terms_ids"]) > 0 + ) + assert isinstance(response["royalty_vault"], str) and response["royalty_vault"] + # Add this test class to your existing test_integration_ip_asset.py file From a1d85e84dd03fd2ee3dbbe0e099bbf85e0a3d4a9 Mon Sep 17 00:00:00 2001 From: Bonnie Date: Thu, 16 Oct 2025 17:32:56 +0800 Subject: [PATCH 5/9] feat: enhance IPAsset class with new test cases for minting, registering IP, and attaching PIL terms with royalty distribution --- .../resources/IPAsset.py | 17 +- .../integration/test_integration_ip_asset.py | 532 +++++++----------- tests/unit/fixtures/data.py | 37 ++ tests/unit/resources/test_ip_asset.py | 162 +++++- 4 files changed, 397 insertions(+), 351 deletions(-) diff --git a/src/story_protocol_python_sdk/resources/IPAsset.py b/src/story_protocol_python_sdk/resources/IPAsset.py index 9b93a1f..08b9aae 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 dataclasses import asdict +from dataclasses import asdict, is_dataclass from ens.ens import Address, HexStr from web3 import Web3 @@ -1376,24 +1376,29 @@ def _validate_recipient(self, recipient: Address | None) -> Address: return validate_address(recipient) def _validate_license_terms_data( - self, license_terms_data: list[LicenseTermsDataInput] + self, license_terms_data: list[LicenseTermsDataInput] | list[dict] ) -> list: """ Validate the license terms data. - :param license_terms_data `list[LicenseTermsDataInput]`: The license terms data to validate. + :param license_terms_data `list[LicenseTermsDataInput]` or `list[dict]`: The license terms data to validate. :return list: The validated license terms data. """ validated_license_terms_data = [] for term in license_terms_data: - # Convert dataclass to dict for validation - terms_dict = asdict(term.terms) + if is_dataclass(term): + terms_dict = asdict(term.terms) + licensing_config_dict = term.licensing_config + else: + terms_dict = term["terms"] + licensing_config_dict = term["licensing_config"] + validated_license_terms_data.append( { "terms": self.license_terms_util.validate_license_terms(terms_dict), "licensingConfig": self.license_terms_util.validate_licensing_config( - term.licensing_config + licensing_config_dict ), } ) diff --git a/tests/integration/test_integration_ip_asset.py b/tests/integration/test_integration_ip_asset.py index 2e9bea2..5d5ff4a 100644 --- a/tests/integration/test_integration_ip_asset.py +++ b/tests/integration/test_integration_ip_asset.py @@ -1,7 +1,6 @@ import pytest from story_protocol_python_sdk import ( - ROYALTY_POLICY_LAP_ADDRESS, ZERO_ADDRESS, ZERO_HASH, DerivativeDataInput, @@ -32,6 +31,14 @@ web3, ) +# Common test data for IP metadata +COMMON_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-nft.json", + nft_metadata_hash=web3.keccak(text="custom-value-nft-metadata"), +) + class TestIPAssetRegistration: @pytest.fixture(scope="module") @@ -379,27 +386,77 @@ def test_mint_register_ip(self, story_client: StoryClient, nft_collection): spg_nft_contract=nft_collection, ip_metadata=metadata ) + def test_mint_and_register_ip_and_make_derivative( + self, story_client: StoryClient, nft_collection, parent_ip_and_license_terms + ): + """Test minting NFT, registering IP and making derivative with custom derivative data and metadata""" + 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=COMMON_IP_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) -class TestSPGNFTOperations: - - # 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") + def test_mint_and_register_ip_and_make_derivative_with_license_tokens( + self, + story_client: StoryClient, + nft_collection, + mint_and_approve_license_token, + ): + """Test minting NFT, registering IP and making derivative using license tokens with custom metadata""" + license_token_ids = mint_and_approve_license_token + response = story_client.IPAsset.mint_and_register_ip_and_make_derivative_with_license_tokens( + spg_nft_contract=nft_collection, + license_token_ids=[license_token_ids[1]], + max_rts=100000000, + ip_metadata=COMMON_IP_METADATA, + recipient=account_2.address, + allow_duplicates=True, + ) + assert response is not None + assert isinstance(response["tx_hash"], str) + assert isinstance(response["ip_id"], str) + assert isinstance(response["token_id"], int) - # response = story_client.IPAsset.register( - # nft_contract=nft_collection, - # token_id=token_id, - # ip_metadata={ - # 'ip_metadata_uri': "test-uri", - # 'ip_metadata_hash': web3.to_hex(web3.keccak(text="test-metadata-hash")), - # 'nft_metadata_hash': web3.to_hex(web3.keccak(text="test-nft-metadata-hash")) - # }, - # deadline=1000 - # ) + def test_mint_and_register_ip_and_make_derivative_and_distribute_royalty_tokens( + self, story_client: StoryClient, nft_collection, parent_ip_and_license_terms + ): + """Test minting NFT, registering IP, making derivative and distributing royalty tokens with custom derivative data""" + response = story_client.IPAsset.mint_and_register_ip_and_make_derivative_and_distribute_royalty_tokens( + 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, + ), + royalty_shares=[ + RoyaltyShareInput(recipient=account.address, percentage=60), + RoyaltyShareInput(recipient=account_2.address, percentage=40), + ], + ip_metadata=COMMON_IP_METADATA, + recipient=account_2.address, + allow_duplicates=True, + ) + assert isinstance(response["tx_hash"], str) + assert isinstance(response["ip_id"], str) + assert isinstance(response["token_id"], int) + assert isinstance(response["royalty_vault"], str) - # assert 'ip_id' in response - # assert isinstance(response['ip_id'], str) - # assert response['ip_id'] != '' +class TestSPGNFTOperations: def test_register_derivative_ip( self, story_client: StoryClient, parent_ip_and_license_terms, nft_collection ): @@ -472,8 +529,11 @@ def test_register_derivative_ip( assert isinstance(result["ip_id"], str) and result["ip_id"] def test_register_ip_and_attach_pil_terms( - self, story_client: StoryClient, nft_collection, parent_ip_and_license_terms + self, + story_client: StoryClient, + nft_collection, ): + """Test registering IP and attaching multiple PIL terms with custom terms data""" token_id = mint_by_spg(nft_collection, story_client.web3, story_client.account) # Register IP and attach PIL terms @@ -558,53 +618,47 @@ def test_mint_and_register_ip_and_attach_pil_terms_and_distribute_royalty_tokens self, story_client: StoryClient, nft_collection ): """Test minting NFT, registering IP, attaching PIL terms and distributing royalty tokens with all optional parameters""" - license_terms_data = [ - LicenseTermsDataInput( - terms=LicenseTermsInput( - transferable=True, - royalty_policy=ROYALTY_POLICY, - default_minting_fee=10000, - expiration=1000, - commercial_use=True, - commercial_attribution=False, - commercializer_checker=ZERO_ADDRESS, - commercializer_checker_data=ZERO_HASH, - 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=WIP_TOKEN_ADDRESS, - uri="test case with custom values", - ), - licensing_config={ - "is_set": True, - "minting_fee": 10000, - "licensing_hook": ZERO_ADDRESS, - "hook_data": ZERO_HASH, - "commercial_rev_share": 10, - "disabled": False, - "expect_minimum_group_reward_share": 0, - "expect_group_reward_pool": ZERO_ADDRESS, - }, - ) - ] royalty_shares = [ RoyaltyShareInput(recipient=account_2.address, percentage=50.0) ] response = story_client.IPAsset.mint_and_register_ip_and_attach_pil_terms_and_distribute_royalty_tokens( spg_nft_contract=nft_collection, - license_terms_data=license_terms_data, + license_terms_data=[ + LicenseTermsDataInput( + terms=LicenseTermsInput( + transferable=True, + royalty_policy=ROYALTY_POLICY, + default_minting_fee=10000, + expiration=1000, + commercial_use=True, + commercial_attribution=False, + commercializer_checker=ZERO_ADDRESS, + commercializer_checker_data=ZERO_HASH, + 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=WIP_TOKEN_ADDRESS, + uri="test case with custom values", + ), + licensing_config={ + "is_set": True, + "minting_fee": 10000, + "licensing_hook": ZERO_ADDRESS, + "hook_data": ZERO_HASH, + "commercial_rev_share": 10, + "disabled": False, + "expect_minimum_group_reward_share": 0, + "expect_group_reward_pool": ZERO_ADDRESS, + }, + ) + ], royalty_shares=royalty_shares, - 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-nft.json", - nft_metadata_hash=web3.keccak(text="custom-value-nft-metadata"), - ), + ip_metadata=COMMON_IP_METADATA, recipient=account_2.address, allow_duplicates=False, ) @@ -618,8 +672,83 @@ def test_mint_and_register_ip_and_attach_pil_terms_and_distribute_royalty_tokens ) assert isinstance(response["royalty_vault"], str) and response["royalty_vault"] - -# Add this test class to your existing test_integration_ip_asset.py file + def test_register_pil_terms_and_attach( + self, + story_client: StoryClient, + parent_ip_and_license_terms, + ): + """Test registering PIL terms and attaching them to an existing IP with multiple 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 class TestIPAssetMint: @@ -828,284 +957,3 @@ 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) - - -class TestMintAndRegisterIpAndMakeDerivativeWithLicenseTokens: - def test_default_value( - self, - story_client: StoryClient, - nft_collection, - mint_and_approve_license_token, - ): - # Get second parent ip and license terms - second_parent_ip_and_license_terms = ( - 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, - }, - } - ], - allow_duplicates=True, - ) - ) - # Mint license tokens for second parent ip - second_license_token_ids = story_client.License.mint_license_tokens( - licensor_ip_id=second_parent_ip_and_license_terms["ip_id"], - license_template=PIL_LICENSE_TEMPLATE, - license_terms_id=second_parent_ip_and_license_terms["license_terms_ids"][0], - amount=1, - receiver=account.address, - max_revenue_share=100, - ) - # Approve license tokens for derivative workflows - 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=second_license_token_ids["license_token_ids"][0], - ) - # Mint and register ip and make derivative with license tokens - response = story_client.IPAsset.mint_and_register_ip_and_make_derivative_with_license_tokens( - spg_nft_contract=nft_collection, - license_token_ids=[ - mint_and_approve_license_token[0], - second_license_token_ids["license_token_ids"][0], - ], - max_rts=100000000, - ) - 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, - mint_and_approve_license_token, - ): - license_token_ids = mint_and_approve_license_token - response = story_client.IPAsset.mint_and_register_ip_and_make_derivative_with_license_tokens( - spg_nft_contract=nft_collection, - license_token_ids=[license_token_ids[1]], - max_rts=100000000, - 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=True, - ) - assert response is not None - 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 - - -class TestMintAndRegisterIpAndMakeDerivativeAndDistributeRoyaltyTokens: - def test_mint_register_ip_make_derivative_distribute_royalty_tokens_default_value( - self, story_client: StoryClient, nft_collection, parent_ip_and_license_terms - ): - response = story_client.IPAsset.mint_and_register_ip_and_make_derivative_and_distribute_royalty_tokens( - 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"]], - ), - royalty_shares=[ - RoyaltyShareInput(recipient=account.address, percentage=50.000032222), - RoyaltyShareInput(recipient=account_2.address, percentage=30.000032222), - ], - ) - assert isinstance(response["tx_hash"], str) - assert isinstance(response["ip_id"], str) - assert isinstance(response["token_id"], int) - assert isinstance(response["royalty_vault"], str) - - def test_mint_register_ip_make_derivative_distribute_royalty_tokens_with_custom_values( - self, story_client: StoryClient, nft_collection, parent_ip_and_license_terms - ): - response = story_client.IPAsset.mint_and_register_ip_and_make_derivative_and_distribute_royalty_tokens( - 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, - ), - royalty_shares=[ - RoyaltyShareInput(recipient=account.address, percentage=60), - RoyaltyShareInput(recipient=account_2.address, percentage=40), - ], - ip_metadata=IPMetadataInput( - ip_metadata_uri="https://example.com/ip-metadata", - ip_metadata_hash=web3.keccak(text="ip_metadata_hash"), - nft_metadata_uri="https://example.com/nft-metadata", - nft_metadata_hash=web3.keccak(text="nft_metadata_hash"), - ), - recipient=account_2.address, - allow_duplicates=False, - ) - assert isinstance(response["tx_hash"], str) - assert isinstance(response["ip_id"], str) - assert isinstance(response["token_id"], int) - assert isinstance(response["royalty_vault"], str) diff --git a/tests/unit/fixtures/data.py b/tests/unit/fixtures/data.py index 9b3efb9..17edef0 100644 --- a/tests/unit/fixtures/data.py +++ b/tests/unit/fixtures/data.py @@ -1,3 +1,6 @@ +from story_protocol_python_sdk import LicenseTermsDataInput, LicenseTermsInput +from story_protocol_python_sdk.utils.constants import ZERO_ADDRESS, ZERO_HASH + CHAIN_ID = 1315 ADDRESS = "0x1234567890123456789012345678901234567890" TX_HASH = b"tx_hash_bytes" @@ -34,3 +37,37 @@ "expect_group_reward_pool": ADDRESS, } ACCOUNT_ADDRESS = "0xF60cBF0Ea1A61567F1dDaf79A6219D20d189155c" + +LICENSE_TERMS_DATA = [ + LicenseTermsDataInput( + terms=LicenseTermsInput( + transferable=True, + royalty_policy=ADDRESS, + default_minting_fee=10000, + expiration=1000, + commercial_use=True, + commercial_attribution=False, + commercializer_checker=ZERO_ADDRESS, + commercializer_checker_data=ZERO_HASH, + 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=ADDRESS, + uri="test-uri", + ), + licensing_config={ + "is_set": True, + "minting_fee": 10, + "licensing_hook": ADDRESS, + "hook_data": ZERO_HASH, + "commercial_rev_share": 10, + "disabled": False, + "expect_minimum_group_reward_share": 0, + "expect_group_reward_pool": ZERO_ADDRESS, + }, + ) +] diff --git a/tests/unit/resources/test_ip_asset.py b/tests/unit/resources/test_ip_asset.py index cc4a2c1..78701d7 100644 --- a/tests/unit/resources/test_ip_asset.py +++ b/tests/unit/resources/test_ip_asset.py @@ -3,24 +3,25 @@ import pytest from ens.ens import HexStr +from story_protocol_python_sdk import RoyaltyShareInput 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.types.resource.Royalty import RoyaltyShareInput -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, IPMetadataInput from story_protocol_python_sdk.utils.royalty import get_royalty_shares -from tests.integration.config.utils import ZERO_ADDRESS from tests.unit.fixtures.data import ( ACCOUNT_ADDRESS, ADDRESS, CHAIN_ID, IP_ID, LICENSE_TERMS, + LICENSE_TERMS_DATA, LICENSING_CONFIG, TX_HASH, + ZERO_ADDRESS, + ZERO_HASH, ) @@ -1186,3 +1187,158 @@ def test_throw_error_when_transaction_failed( ) ], ) + + +class TestMintAndRegisterIpAndAttachPilTermsAndDistributeRoyaltyTokens: + def test_throw_error_when_royalty_shares_empty(self, ip_asset: IPAsset): + with pytest.raises( + ValueError, + match="Failed to mint, register IP, attach PIL terms and distribute royalty tokens: Royalty shares must be provided.", + ): + ip_asset.mint_and_register_ip_and_attach_pil_terms_and_distribute_royalty_tokens( + spg_nft_contract=ADDRESS, + license_terms_data=LICENSE_TERMS_DATA, + royalty_shares=[], + ) + + def test_success_with_default_values( + self, + ip_asset: IPAsset, + mock_license_registry_client, + mock_parse_ip_registered_event, + mock_parse_tx_license_terms_attached_event, + mock_get_royalty_vault_address_by_ip_id, + ): + royalty_shares = [ + RoyaltyShareInput(recipient=ACCOUNT_ADDRESS, percentage=50.0), + RoyaltyShareInput(recipient=ADDRESS, percentage=30.0), + ] + + with ( + mock_parse_ip_registered_event(), + mock_parse_tx_license_terms_attached_event(), + mock_license_registry_client(), + mock_get_royalty_vault_address_by_ip_id(), + ): + with patch.object( + ip_asset.royalty_token_distribution_workflows_client, + "build_mintAndRegisterIpAndAttachPILTermsAndDistributeRoyaltyTokens_transaction", + return_value={"tx_hash": TX_HASH.hex()}, + ) as mock_build_transaction: + result = ip_asset.mint_and_register_ip_and_attach_pil_terms_and_distribute_royalty_tokens( + spg_nft_contract=ADDRESS, + license_terms_data=LICENSE_TERMS_DATA, + royalty_shares=royalty_shares, + ) + called_args = mock_build_transaction.call_args[0] + assert called_args[2] == IPMetadata.from_input().get_validated_data() + assert ( + called_args[4] == get_royalty_shares(royalty_shares)["royalty_shares"] + ) + assert called_args[5] is True + + assert result["tx_hash"] == TX_HASH.hex() + assert result["ip_id"] == IP_ID + assert result["token_id"] == 3 + assert result["license_terms_ids"] == [1, 2] + assert result["royalty_vault"] == ADDRESS + + def test_success_with_custom_values( + self, + ip_asset: IPAsset, + mock_license_registry_client, + mock_parse_ip_registered_event, + mock_parse_tx_license_terms_attached_event, + mock_get_royalty_vault_address_by_ip_id, + ): + royalty_shares = [ + RoyaltyShareInput(recipient=ACCOUNT_ADDRESS, percentage=50.0), + RoyaltyShareInput(recipient=ADDRESS, percentage=30.0), + ] + custom_metadata = IPMetadataInput( + ip_metadata_uri="https://example.com/ip-metadata.json", + ip_metadata_hash=HexStr("0x" + "a" * 64), + nft_metadata_uri="https://example.com/nft-metadata.json", + nft_metadata_hash=HexStr("0x" + "b" * 64), + ) + custom_recipient = ACCOUNT_ADDRESS + + with ( + mock_parse_ip_registered_event(), + mock_parse_tx_license_terms_attached_event(), + mock_license_registry_client(), + mock_get_royalty_vault_address_by_ip_id(), + ): + with patch.object( + ip_asset.royalty_token_distribution_workflows_client, + "build_mintAndRegisterIpAndAttachPILTermsAndDistributeRoyaltyTokens_transaction", + return_value={"tx_hash": TX_HASH.hex()}, + ) as mock_build_transaction: + result = ip_asset.mint_and_register_ip_and_attach_pil_terms_and_distribute_royalty_tokens( + spg_nft_contract=ADDRESS, + license_terms_data=LICENSE_TERMS_DATA, + royalty_shares=royalty_shares, + ip_metadata=custom_metadata, + recipient=custom_recipient, + allow_duplicates=False, + ) + called_args = mock_build_transaction.call_args[0] + assert called_args[0] == ADDRESS + assert called_args[1] == custom_recipient + assert ( + called_args[2] + == IPMetadata.from_input(custom_metadata).get_validated_data() + ) + assert ( + called_args[4] == get_royalty_shares(royalty_shares)["royalty_shares"] + ) + assert called_args[5] is False + + assert result["tx_hash"] == TX_HASH.hex() + assert result["ip_id"] == IP_ID + assert result["token_id"] == 3 + assert result["license_terms_ids"] == [1, 2] + assert result["royalty_vault"] == ADDRESS + + def test_success_with_tx_options( + self, + ip_asset: IPAsset, + mock_license_registry_client, + mock_parse_ip_registered_event, + mock_parse_tx_license_terms_attached_event, + mock_get_royalty_vault_address_by_ip_id, + ): + royalty_shares = [ + RoyaltyShareInput(recipient=ACCOUNT_ADDRESS, percentage=50.0), + RoyaltyShareInput(recipient=ADDRESS, percentage=30.0), + ] + tx_options = {"gas": 500000, "gasPrice": 1000000000} + + with ( + mock_parse_ip_registered_event(), + mock_parse_tx_license_terms_attached_event(), + mock_license_registry_client(), + mock_get_royalty_vault_address_by_ip_id(), + ): + with patch( + "story_protocol_python_sdk.resources.IPAsset.build_and_send_transaction" + ) as mock_build_and_send: + mock_build_and_send.return_value = { + "tx_hash": TX_HASH.hex(), + "tx_receipt": "mock_receipt", + } + result = ip_asset.mint_and_register_ip_and_attach_pil_terms_and_distribute_royalty_tokens( + spg_nft_contract=ADDRESS, + license_terms_data=LICENSE_TERMS_DATA, + royalty_shares=royalty_shares, + tx_options=tx_options, + ) + + called_kwargs = mock_build_and_send.call_args[1] + assert called_kwargs["tx_options"] == tx_options + + assert result["tx_hash"] == TX_HASH.hex() + assert result["ip_id"] == IP_ID + assert result["token_id"] == 3 + assert result["license_terms_ids"] == [1, 2] + assert result["royalty_vault"] == ADDRESS From 576e32dab3126c527bc944333161fe3c8ffad852 Mon Sep 17 00:00:00 2001 From: Bonnie Date: Thu, 16 Oct 2025 17:45:37 +0800 Subject: [PATCH 6/9] feat: add licensing configuration to IPAsset tests and enhance type annotations for clarity --- tests/unit/resources/test_ip_asset.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/unit/resources/test_ip_asset.py b/tests/unit/resources/test_ip_asset.py index 78701d7..306e3f2 100644 --- a/tests/unit/resources/test_ip_asset.py +++ b/tests/unit/resources/test_ip_asset.py @@ -267,7 +267,7 @@ def test_token_id_is_already_registered( ) def test_royalty_policy_commercial_rev_share_is_less_than_0( - self, ip_asset, mock_get_ip_id, mock_is_registered + self, ip_asset: IPAsset, mock_get_ip_id, mock_is_registered ): with mock_get_ip_id(), mock_is_registered(): with pytest.raises( @@ -282,6 +282,7 @@ def test_royalty_policy_commercial_rev_share_is_less_than_0( **LICENSE_TERMS, "commercial_rev_share": -1, }, + "licensing_config": LICENSING_CONFIG, } ], ) From 9b197238f755d42894e5ae3b0992239f88b57cc8 Mon Sep 17 00:00:00 2001 From: Bonnie Date: Thu, 16 Oct 2025 17:52:52 +0800 Subject: [PATCH 7/9] refactor: move test_mint_and_register_ip_and_attach_pil_terms_and_distribute_royalty_tokens to TestIPAssetMint class for better organization --- .../integration/test_integration_ip_asset.py | 116 +++++++++--------- 1 file changed, 58 insertions(+), 58 deletions(-) diff --git a/tests/integration/test_integration_ip_asset.py b/tests/integration/test_integration_ip_asset.py index 5d5ff4a..da74da6 100644 --- a/tests/integration/test_integration_ip_asset.py +++ b/tests/integration/test_integration_ip_asset.py @@ -614,64 +614,6 @@ def test_register_ip_and_attach_pil_terms( and result["license_terms_ids"] ) - def test_mint_and_register_ip_and_attach_pil_terms_and_distribute_royalty_tokens( - self, story_client: StoryClient, nft_collection - ): - """Test minting NFT, registering IP, attaching PIL terms and distributing royalty tokens with all optional parameters""" - royalty_shares = [ - RoyaltyShareInput(recipient=account_2.address, percentage=50.0) - ] - - response = story_client.IPAsset.mint_and_register_ip_and_attach_pil_terms_and_distribute_royalty_tokens( - spg_nft_contract=nft_collection, - license_terms_data=[ - LicenseTermsDataInput( - terms=LicenseTermsInput( - transferable=True, - royalty_policy=ROYALTY_POLICY, - default_minting_fee=10000, - expiration=1000, - commercial_use=True, - commercial_attribution=False, - commercializer_checker=ZERO_ADDRESS, - commercializer_checker_data=ZERO_HASH, - 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=WIP_TOKEN_ADDRESS, - uri="test case with custom values", - ), - licensing_config={ - "is_set": True, - "minting_fee": 10000, - "licensing_hook": ZERO_ADDRESS, - "hook_data": ZERO_HASH, - "commercial_rev_share": 10, - "disabled": False, - "expect_minimum_group_reward_share": 0, - "expect_group_reward_pool": ZERO_ADDRESS, - }, - ) - ], - royalty_shares=royalty_shares, - ip_metadata=COMMON_IP_METADATA, - recipient=account_2.address, - allow_duplicates=False, - ) - - 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) - assert ( - isinstance(response["license_terms_ids"], list) - and len(response["license_terms_ids"]) > 0 - ) - assert isinstance(response["royalty_vault"], str) and response["royalty_vault"] - def test_register_pil_terms_and_attach( self, story_client: StoryClient, @@ -957,3 +899,61 @@ def test_mint_with_existing_metadata_hash_no_duplicates( metadata_hash=metadata_hash, allow_duplicates=False, ) + + def test_mint_and_register_ip_and_attach_pil_terms_and_distribute_royalty_tokens( + self, story_client: StoryClient, nft_collection + ): + """Test minting NFT, registering IP, attaching PIL terms and distributing royalty tokens with all optional parameters""" + royalty_shares = [ + RoyaltyShareInput(recipient=account_2.address, percentage=50.0) + ] + + response = story_client.IPAsset.mint_and_register_ip_and_attach_pil_terms_and_distribute_royalty_tokens( + spg_nft_contract=nft_collection, + license_terms_data=[ + LicenseTermsDataInput( + terms=LicenseTermsInput( + transferable=True, + royalty_policy=ROYALTY_POLICY, + default_minting_fee=10000, + expiration=1000, + commercial_use=True, + commercial_attribution=False, + commercializer_checker=ZERO_ADDRESS, + commercializer_checker_data=ZERO_HASH, + 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=WIP_TOKEN_ADDRESS, + uri="test case with custom values", + ), + licensing_config={ + "is_set": True, + "minting_fee": 10000, + "licensing_hook": ZERO_ADDRESS, + "hook_data": ZERO_HASH, + "commercial_rev_share": 10, + "disabled": False, + "expect_minimum_group_reward_share": 0, + "expect_group_reward_pool": ZERO_ADDRESS, + }, + ) + ], + royalty_shares=royalty_shares, + ip_metadata=COMMON_IP_METADATA, + recipient=account_2.address, + allow_duplicates=False, + ) + + 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) + assert ( + isinstance(response["license_terms_ids"], list) + and len(response["license_terms_ids"]) > 0 + ) + assert isinstance(response["royalty_vault"], str) and response["royalty_vault"] From 4ba3267cce001f65fe7d2c98bad7b8e70b738e0a Mon Sep 17 00:00:00 2001 From: Bonnie Date: Fri, 17 Oct 2025 10:50:08 +0800 Subject: [PATCH 8/9] docs: improve documentation clarity in IPAsset and royalty utility functions by adding missing punctuation --- src/story_protocol_python_sdk/types/resource/IPAsset.py | 2 +- src/story_protocol_python_sdk/utils/royalty.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/story_protocol_python_sdk/types/resource/IPAsset.py b/src/story_protocol_python_sdk/types/resource/IPAsset.py index a8133c7..582c2b6 100644 --- a/src/story_protocol_python_sdk/types/resource/IPAsset.py +++ b/src/story_protocol_python_sdk/types/resource/IPAsset.py @@ -44,7 +44,7 @@ class RegistrationWithRoyaltyVaultAndLicenseTermsResponse( Extends `RegistrationWithRoyaltyVaultResponse` with license terms information. Attributes: - license_terms_ids: The IDs of the license terms attached to the IP asset + license_terms_ids: The IDs of the license terms attached to the IP asset. """ license_terms_ids: list[int] diff --git a/src/story_protocol_python_sdk/utils/royalty.py b/src/story_protocol_python_sdk/utils/royalty.py index d4f015b..367fca1 100644 --- a/src/story_protocol_python_sdk/utils/royalty.py +++ b/src/story_protocol_python_sdk/utils/royalty.py @@ -19,8 +19,8 @@ def get_royalty_shares(royalty_shares: list[RoyaltyShareInput]) -> dict: """ Validate and convert royalty shares. - :param royalty_shares: List of `RoyaltyShareInput` - :return: Dictionary with validated royalty_shares and total_amount + :param royalty_shares: List of `RoyaltyShareInput`. + :return: Dictionary with validated royalty_shares and total_amount. """ if len(royalty_shares) == 0: raise ValueError("Royalty shares must be provided.") @@ -77,7 +77,7 @@ def royalty_policy_input_to_address( - If NativeRoyaltyPolicy.LRP (1), returns the LRP policy address Raises: - ValueError: If the custom address is invalid + ValueError: If the custom address is invalid. """ if input is None: return ROYALTY_POLICY_LAP_ADDRESS From 3f14cd8cc34b0d4db876636b62842b90d0a1a0ae Mon Sep 17 00:00:00 2001 From: Bonnie57 <146059114+bonnie57@users.noreply.github.com> Date: Tue, 21 Oct 2025 10:22:13 +0800 Subject: [PATCH 9/9] feat: add the register_ip_and_attach_pil_terms_and_distribute_royalty_tokens method (#152) * test: add integration test for registering IP, attaching PIL terms, and distributing royalty tokens in IPAsset class * feat: add IPMetadataInput and related tests for registering IP with metadata in IPAsset class * fix: update type annotation for royalty_shares parameter in _distribute_royalty_tokens method to use RoyaltyShareInput for improved type safety --- src/story_protocol_python_sdk/__init__.py | 2 + .../resources/IPAsset.py | 182 ++++++++++ .../types/resource/IPAsset.py | 15 + .../integration/test_integration_ip_asset.py | 70 ++++ tests/unit/fixtures/data.py | 14 +- tests/unit/resources/test_ip_asset.py | 317 ++++++++++++++++-- 6 files changed, 565 insertions(+), 35 deletions(-) diff --git a/src/story_protocol_python_sdk/__init__.py b/src/story_protocol_python_sdk/__init__.py index c7dd0a5..6e2d56e 100644 --- a/src/story_protocol_python_sdk/__init__.py +++ b/src/story_protocol_python_sdk/__init__.py @@ -15,6 +15,7 @@ ) from .types.resource.IPAsset import ( LicenseTermsDataInput, + RegisterAndAttachAndDistributeRoyaltyTokensResponse, RegisterPILTermsAndAttachResponse, RegistrationResponse, RegistrationWithRoyaltyVaultAndLicenseTermsResponse, @@ -50,6 +51,7 @@ "RegistrationResponse", "RegistrationWithRoyaltyVaultResponse", "RegistrationWithRoyaltyVaultAndLicenseTermsResponse", + "RegisterAndAttachAndDistributeRoyaltyTokensResponse", "LicenseTermsDataInput", "ClaimRewardsResponse", "ClaimReward", diff --git a/src/story_protocol_python_sdk/resources/IPAsset.py b/src/story_protocol_python_sdk/resources/IPAsset.py index 08b9aae..d191cb9 100644 --- a/src/story_protocol_python_sdk/resources/IPAsset.py +++ b/src/story_protocol_python_sdk/resources/IPAsset.py @@ -20,6 +20,9 @@ from story_protocol_python_sdk.abi.IPAssetRegistry.IPAssetRegistry_client import ( IPAssetRegistryClient, ) +from story_protocol_python_sdk.abi.IpRoyaltyVaultImpl.IpRoyaltyVaultImpl_client import ( + IpRoyaltyVaultImplClient, +) from story_protocol_python_sdk.abi.LicenseAttachmentWorkflows.LicenseAttachmentWorkflows_client import ( LicenseAttachmentWorkflowsClient, ) @@ -48,6 +51,7 @@ from story_protocol_python_sdk.types.common import AccessPermission from story_protocol_python_sdk.types.resource.IPAsset import ( LicenseTermsDataInput, + RegisterAndAttachAndDistributeRoyaltyTokensResponse, RegisterPILTermsAndAttachResponse, RegistrationResponse, RegistrationWithRoyaltyVaultAndLicenseTermsResponse, @@ -1095,6 +1099,120 @@ def mint_and_register_ip_and_make_derivative_and_distribute_royalty_tokens( f"Failed to mint, register IP, make derivative and distribute royalty tokens: {str(e)}" ) from e + def register_ip_and_attach_pil_terms_and_distribute_royalty_tokens( + self, + nft_contract: Address, + token_id: int, + license_terms_data: list[LicenseTermsDataInput], + royalty_shares: list[RoyaltyShareInput], + ip_metadata: IPMetadataInput | None = None, + deadline: int | None = None, + tx_options: dict | None = None, + ) -> RegisterAndAttachAndDistributeRoyaltyTokensResponse: + """ + Register the given NFT and attach license terms and distribute royalty + tokens. In order to successfully distribute royalty tokens, the first license terms + attached to the IP must be a commercial license. + + :param nft_contract Address: The address of the NFT collection. + :param token_id int: The ID of the NFT. + :param license_terms_data `list[LicenseTermsDataInput]`: The data of the license and its configuration to be attached to the new group IP. + :param royalty_shares `list[RoyaltyShareInput]`: Authors of the IP and their shares of the royalty tokens. + :param ip_metadata `IPMetadataInput`: [Optional] The metadata for the newly registered IP. + :param deadline int: [Optional] The deadline for the signature in seconds. (default: 1000 seconds) + :param tx_options dict: [Optional] Transaction options. + :return `RegisterAndAttachAndDistributeRoyaltyTokensResponse`: Response with tx hash, license terms IDs, royalty vault address, and distribute royalty tokens transaction hash. + """ + try: + nft_contract = validate_address(nft_contract) + 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." + ) + + license_terms = self._validate_license_terms_data(license_terms_data) + calculated_deadline = self.sign_util.get_deadline(deadline=deadline) + royalty_shares_obj = get_royalty_shares(royalty_shares) + + signature_response = self.sign_util.get_permission_signature( + ip_id=ip_id, + deadline=calculated_deadline, + state=self.web3.to_bytes(hexstr=HexStr(ZERO_HASH)), + permissions=[ + { + "ipId": ip_id, + "signer": self.royalty_token_distribution_workflows_client.contract.address, + "to": self.core_metadata_module_client.contract.address, + "permission": AccessPermission.ALLOW, + "func": "setAll(address,string,bytes32,bytes32)", + }, + { + "ipId": ip_id, + "signer": self.royalty_token_distribution_workflows_client.contract.address, + "to": self.licensing_module_client.contract.address, + "permission": AccessPermission.ALLOW, + "func": "attachLicenseTerms(address,address,uint256)", + }, + { + "ipId": ip_id, + "signer": self.royalty_token_distribution_workflows_client.contract.address, + "to": self.licensing_module_client.contract.address, + "permission": AccessPermission.ALLOW, + "func": "setLicensingConfig(address,address,uint256,(bool,uint256,address,bytes,uint32,bool,uint32,address))", + }, + ], + ) + + response = build_and_send_transaction( + self.web3, + self.account, + self.royalty_token_distribution_workflows_client.build_registerIpAndAttachPILTermsAndDeployRoyaltyVault_transaction, + nft_contract, + token_id, + IPMetadata.from_input(ip_metadata).get_validated_data(), + license_terms, + { + "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"]) + license_terms_ids = self._parse_tx_license_terms_attached_event( + response["tx_receipt"] + ) + royalty_vault = self.get_royalty_vault_address_by_ip_id( + response["tx_receipt"], + ip_registered["ip_id"], + ) + + # Distribute royalty tokens + distribute_tx_hash = self._distribute_royalty_tokens( + ip_id=ip_registered["ip_id"], + royalty_shares=royalty_shares_obj["royalty_shares"], + royalty_vault=royalty_vault, + total_amount=royalty_shares_obj["total_amount"], + tx_options=tx_options, + deadline=calculated_deadline, + ) + + return RegisterAndAttachAndDistributeRoyaltyTokensResponse( + tx_hash=response["tx_hash"], + license_terms_ids=license_terms_ids, + royalty_vault=royalty_vault, + distribute_royalty_tokens_tx_hash=distribute_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, attach PIL terms and distribute royalty tokens: {str(e)}" + ) from e + def register_pil_terms_and_attach( self, ip_id: Address, @@ -1261,6 +1379,70 @@ def _validate_license_token_ids(self, license_token_ids: list) -> list: return license_token_ids + def _distribute_royalty_tokens( + self, + ip_id: Address, + royalty_shares: list[RoyaltyShareInput], + deadline: int, + royalty_vault: Address, + total_amount: int, + tx_options: dict | None = None, + ) -> HexStr: + """ + Distribute royalty tokens to specified recipients. + + This is an internal method that handles the distribution of royalty tokens + from an IP's royalty vault to the specified recipients. + + :param ip_id Address: The IP ID. + :param royalty_shares list[RoyaltyShareInput]: The validated royalty shares with recipient and percentage. + :param deadline int: The deadline for the signature. + :param royalty_vault Address: The address of the royalty vault. + :param total_amount int: The total amount of royalty tokens to distribute. + :param tx_options dict: [Optional] Transaction options. + :return HexStr: The transaction hash. + """ + try: + ip_account_impl_client = IPAccountImplClient(self.web3, ip_id) + state = ip_account_impl_client.state() + + ip_royalty_vault_client = IpRoyaltyVaultImplClient(self.web3, royalty_vault) + + signature_response = self.sign_util.get_signature( + state=state, + to=royalty_vault, + encode_data=ip_royalty_vault_client.contract.encode_abi( + abi_element_identifier="approve", + args=[ + self.royalty_token_distribution_workflows_client.contract.address, + total_amount, + ], + ), + verifying_contract=ip_id, + deadline=deadline, + ) + + response = build_and_send_transaction( + self.web3, + self.account, + self.royalty_token_distribution_workflows_client.build_distributeRoyaltyTokens_transaction, + ip_id, + royalty_shares, + { + "signer": self.web3.to_checksum_address(self.account.address), + "deadline": deadline, + "signature": self.web3.to_bytes( + hexstr=signature_response["signature"] + ), + }, + tx_options=tx_options, + ) + + return response["tx_hash"] + + except Exception as e: + raise ValueError(f"Failed to distribute royalty tokens: {str(e)}") from e + def _get_ip_id(self, token_contract: str, token_id: int) -> str: """ Get the IP ID for a given token. diff --git a/src/story_protocol_python_sdk/types/resource/IPAsset.py b/src/story_protocol_python_sdk/types/resource/IPAsset.py index 582c2b6..3e7c21a 100644 --- a/src/story_protocol_python_sdk/types/resource/IPAsset.py +++ b/src/story_protocol_python_sdk/types/resource/IPAsset.py @@ -63,6 +63,21 @@ class RegisterPILTermsAndAttachResponse(TypedDict): license_terms_ids: list[int] +class RegisterAndAttachAndDistributeRoyaltyTokensResponse( + RegistrationWithRoyaltyVaultAndLicenseTermsResponse +): + """ + Response structure for IP asset registration operations with royalty vault, license terms and distribute royalty tokens. + + Extends `RegistrationWithRoyaltyVaultAndLicenseTermsResponse` with distribute royalty tokens transaction hash. + + Attributes: + distribute_royalty_tokens_tx_hash: The transaction hash of the distribute royalty tokens transaction. + """ + + distribute_royalty_tokens_tx_hash: HexStr + + @dataclass class LicenseTermsDataInput: """ diff --git a/tests/integration/test_integration_ip_asset.py b/tests/integration/test_integration_ip_asset.py index da74da6..6b241f7 100644 --- a/tests/integration/test_integration_ip_asset.py +++ b/tests/integration/test_integration_ip_asset.py @@ -16,6 +16,7 @@ from story_protocol_python_sdk.abi.LicenseToken.LicenseToken_client import ( LicenseTokenClient, ) +from story_protocol_python_sdk.utils.licensing_config_data import LicensingConfig from tests.integration.config.test_config import account_2 from tests.integration.config.utils import approve @@ -692,6 +693,75 @@ def test_register_pil_terms_and_attach( assert isinstance(response["tx_hash"], str) assert len(response["license_terms_ids"]) == 2 + def test_register_ip_and_attach_pil_terms_and_distribute_royalty_tokens( + self, story_client: StoryClient, nft_collection + ): + """Test registering an existing NFT as IP, attaching PIL terms and distributing royalty tokens with all optional parameters""" + # Mint an NFT first + token_id = mint_by_spg(nft_collection, story_client.web3, story_client.account) + + royalty_shares = [ + RoyaltyShareInput(recipient=account.address, percentage=30.0), + RoyaltyShareInput(recipient=account_2.address, percentage=70.0), + ] + + response = story_client.IPAsset.register_ip_and_attach_pil_terms_and_distribute_royalty_tokens( + nft_contract=nft_collection, + token_id=token_id, + license_terms_data=[ + LicenseTermsDataInput( + terms=LicenseTermsInput( + transferable=True, + royalty_policy=ROYALTY_POLICY, + default_minting_fee=10000, + expiration=1000, + commercial_use=True, + commercial_attribution=False, + commercializer_checker=ZERO_ADDRESS, + commercializer_checker_data=ZERO_HASH, + 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=WIP_TOKEN_ADDRESS, + uri="test case with custom values", + ), + licensing_config=LicensingConfig( + is_set=True, + minting_fee=10000, + licensing_hook=ZERO_ADDRESS, + hook_data=ZERO_HASH, + commercial_rev_share=10, + disabled=False, + expect_minimum_group_reward_share=0, + expect_group_reward_pool=ZERO_ADDRESS, + ), + ) + ], + royalty_shares=royalty_shares, + ip_metadata=COMMON_IP_METADATA, + deadline=1000, + ) + + # Verify all response fields + 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) and response["token_id"] == token_id + ) + assert ( + isinstance(response["license_terms_ids"], list) + and len(response["license_terms_ids"]) > 0 + ) + assert isinstance(response["royalty_vault"], str) and response["royalty_vault"] + assert ( + isinstance(response["distribute_royalty_tokens_tx_hash"], str) + and response["distribute_royalty_tokens_tx_hash"] + ) + class TestIPAssetMint: @pytest.fixture(scope="module") diff --git a/tests/unit/fixtures/data.py b/tests/unit/fixtures/data.py index 17edef0..bfca21d 100644 --- a/tests/unit/fixtures/data.py +++ b/tests/unit/fixtures/data.py @@ -1,4 +1,10 @@ -from story_protocol_python_sdk import LicenseTermsDataInput, LicenseTermsInput +from ens.ens import HexStr + +from story_protocol_python_sdk import ( + IPMetadataInput, + LicenseTermsDataInput, + LicenseTermsInput, +) from story_protocol_python_sdk.utils.constants import ZERO_ADDRESS, ZERO_HASH CHAIN_ID = 1315 @@ -71,3 +77,9 @@ }, ) ] +IP_METADATA = IPMetadataInput( + ip_metadata_uri="https://example.com/ip-metadata.json", + ip_metadata_hash=HexStr("0x" + "a" * 64), + nft_metadata_uri="https://example.com/nft-metadata.json", + nft_metadata_hash=HexStr("0x" + "b" * 64), +) diff --git a/tests/unit/resources/test_ip_asset.py b/tests/unit/resources/test_ip_asset.py index 306e3f2..a10a515 100644 --- a/tests/unit/resources/test_ip_asset.py +++ b/tests/unit/resources/test_ip_asset.py @@ -16,6 +16,7 @@ ADDRESS, CHAIN_ID, IP_ID, + IP_METADATA, LICENSE_TERMS, LICENSE_TERMS_DATA, LICENSING_CONFIG, @@ -212,7 +213,13 @@ def test_success( mock_get_function_signature, mock_license_registry_client, ): - with mock_get_ip_id(), mock_is_registered(), mock_parse_ip_registered_event(), mock_get_function_signature(), mock_license_registry_client(): + with ( + mock_get_ip_id(), + mock_is_registered(), + mock_parse_ip_registered_event(), + mock_get_function_signature(), + mock_license_registry_client(), + ): with mock_signature_related_methods(): result = ip_asset.register_derivative_ip( nft_contract=ADDRESS, @@ -296,7 +303,13 @@ def test_transaction_to_be_called_with_correct_parameters( mock_parse_tx_license_terms_attached_event, mock_signature_related_methods, ): - with mock_get_ip_id(), mock_is_registered(), mock_parse_ip_registered_event(), mock_parse_tx_license_terms_attached_event(), mock_signature_related_methods(): + with ( + mock_get_ip_id(), + mock_is_registered(), + mock_parse_ip_registered_event(), + mock_parse_tx_license_terms_attached_event(), + mock_signature_related_methods(), + ): with patch.object( ip_asset.license_attachment_workflows_client, "build_registerIpAndAttachPILTerms_transaction", @@ -359,7 +372,13 @@ def test_success( mock_signature_related_methods, mock_parse_tx_license_terms_attached_event, ): - with mock_get_ip_id(), mock_is_registered(), mock_parse_ip_registered_event(), mock_parse_tx_license_terms_attached_event(), mock_signature_related_methods(): + with ( + mock_get_ip_id(), + mock_is_registered(), + mock_parse_ip_registered_event(), + mock_parse_tx_license_terms_attached_event(), + mock_signature_related_methods(), + ): result = ip_asset.register_ip_and_attach_pil_terms( nft_contract=ADDRESS, token_id=3, @@ -407,9 +426,12 @@ def test_default_value_when_not_provided( mock_parse_ip_registered_event, mock_license_registry_client, ): - with mock_get_ip_id(), mock_is_registered( - True - ), mock_parse_ip_registered_event(), mock_license_registry_client(): + with ( + mock_get_ip_id(), + mock_is_registered(True), + mock_parse_ip_registered_event(), + mock_license_registry_client(), + ): with patch.object( ip_asset.licensing_module_client, "build_registerDerivative_transaction", @@ -439,9 +461,12 @@ def test_call_value_when_provided( mock_parse_ip_registered_event, mock_license_registry_client, ): - with mock_get_ip_id(), mock_is_registered( - True - ), mock_parse_ip_registered_event(), mock_license_registry_client(): + with ( + mock_get_ip_id(), + mock_is_registered(True), + mock_parse_ip_registered_event(), + mock_license_registry_client(), + ): with patch.object( ip_asset.licensing_module_client, "build_registerDerivative_transaction", @@ -614,8 +639,10 @@ 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 ( + 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." @@ -653,9 +680,14 @@ def test_success_with_default_values( 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 ( + 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", @@ -694,9 +726,14 @@ def test_success_with_custom_values( 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 ( + 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", @@ -922,9 +959,13 @@ def test_successful_registration( 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 ( + 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", @@ -986,9 +1027,13 @@ def test_registration_with_transaction_failed( 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 ( + 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", @@ -1043,7 +1088,11 @@ def test_success_with_default_values( RoyaltyShareInput(recipient=ADDRESS, percentage=30.0), ] - with mock_parse_ip_registered_event(), mock_license_registry_client(), mock_get_royalty_vault_address_by_ip_id(): + with ( + mock_parse_ip_registered_event(), + mock_license_registry_client(), + mock_get_royalty_vault_address_by_ip_id(), + ): with patch.object( ip_asset.royalty_token_distribution_workflows_client, "build_mintAndRegisterIpAndMakeDerivativeAndDistributeRoyaltyTokens_transaction", @@ -1081,7 +1130,11 @@ def test_royalty_vault_address( RoyaltyShareInput(recipient=ADDRESS, percentage=30.0), ] - with mock_parse_ip_registered_event(), mock_license_registry_client(), mock_get_royalty_vault_address_by_ip_id(): + with ( + mock_parse_ip_registered_event(), + mock_license_registry_client(), + mock_get_royalty_vault_address_by_ip_id(), + ): with patch( "story_protocol_python_sdk.resources.IPAsset.build_and_send_transaction", return_value={ @@ -1126,7 +1179,11 @@ def test_success_with_custom_values( nft_metadata_uri="https://example.com/nft-metadata", nft_metadata_hash="0xabcdef1234567890", ) - with mock_parse_ip_registered_event(), mock_license_registry_client(), mock_get_royalty_vault_address_by_ip_id(): + with ( + mock_parse_ip_registered_event(), + mock_license_registry_client(), + mock_get_royalty_vault_address_by_ip_id(), + ): with patch.object( ip_asset.royalty_token_distribution_workflows_client, "build_mintAndRegisterIpAndMakeDerivativeAndDistributeRoyaltyTokens_transaction", @@ -1256,12 +1313,6 @@ def test_success_with_custom_values( RoyaltyShareInput(recipient=ACCOUNT_ADDRESS, percentage=50.0), RoyaltyShareInput(recipient=ADDRESS, percentage=30.0), ] - custom_metadata = IPMetadataInput( - ip_metadata_uri="https://example.com/ip-metadata.json", - ip_metadata_hash=HexStr("0x" + "a" * 64), - nft_metadata_uri="https://example.com/nft-metadata.json", - nft_metadata_hash=HexStr("0x" + "b" * 64), - ) custom_recipient = ACCOUNT_ADDRESS with ( @@ -1279,7 +1330,7 @@ def test_success_with_custom_values( spg_nft_contract=ADDRESS, license_terms_data=LICENSE_TERMS_DATA, royalty_shares=royalty_shares, - ip_metadata=custom_metadata, + ip_metadata=IP_METADATA, recipient=custom_recipient, allow_duplicates=False, ) @@ -1288,7 +1339,7 @@ def test_success_with_custom_values( assert called_args[1] == custom_recipient assert ( called_args[2] - == IPMetadata.from_input(custom_metadata).get_validated_data() + == IPMetadata.from_input(IP_METADATA).get_validated_data() ) assert ( called_args[4] == get_royalty_shares(royalty_shares)["royalty_shares"] @@ -1343,3 +1394,201 @@ def test_success_with_tx_options( assert result["token_id"] == 3 assert result["license_terms_ids"] == [1, 2] assert result["royalty_vault"] == ADDRESS + + +class TestRegisterIpAndAttachPilTermsAndDistributeRoyaltyTokens: + def test_token_id_is_already_registered( + self, ip_asset: IPAsset, mock_get_ip_id, mock_is_registered + ): + with ( + mock_get_ip_id(), + mock_is_registered(True), + ): + with pytest.raises( + ValueError, + match="Failed to register IP, attach PIL terms and distribute royalty tokens: The NFT with id 3 is already registered as IP.", + ): + ip_asset.register_ip_and_attach_pil_terms_and_distribute_royalty_tokens( + nft_contract=ADDRESS, + token_id=3, + license_terms_data=LICENSE_TERMS_DATA, + royalty_shares=[ + RoyaltyShareInput(recipient=ACCOUNT_ADDRESS, percentage=50.0) + ], + ) + + def test_throw_error_when_royalty_shares_empty( + self, ip_asset: IPAsset, mock_get_ip_id, mock_is_registered + ): + with ( + mock_get_ip_id(), + mock_is_registered(), + ): + with pytest.raises( + ValueError, + match="Failed to register IP, attach PIL terms and distribute royalty tokens: Royalty shares must be provided.", + ): + ip_asset.register_ip_and_attach_pil_terms_and_distribute_royalty_tokens( + nft_contract=ADDRESS, + token_id=3, + license_terms_data=LICENSE_TERMS_DATA, + royalty_shares=[], + ) + + def test_success_with_default_values( + self, + ip_asset: IPAsset, + mock_get_ip_id, + mock_is_registered, + mock_parse_ip_registered_event, + mock_parse_tx_license_terms_attached_event, + mock_signature_related_methods, + mock_get_royalty_vault_address_by_ip_id, + mock_ip_account_impl_client, + ): + royalty_shares = [ + RoyaltyShareInput(recipient=ACCOUNT_ADDRESS, percentage=50.0), + RoyaltyShareInput(recipient=ADDRESS, percentage=30.0), + ] + + with ( + mock_get_ip_id(), + mock_is_registered(), + mock_parse_ip_registered_event(), + mock_parse_tx_license_terms_attached_event(), + mock_signature_related_methods(), + mock_get_royalty_vault_address_by_ip_id(), + mock_ip_account_impl_client(), + ): + with ( + patch.object( + ip_asset.royalty_token_distribution_workflows_client, + "build_registerIpAndAttachPILTermsAndDeployRoyaltyVault_transaction", + return_value={"tx_hash": TX_HASH.hex()}, + ), + patch.object( + ip_asset, + "_distribute_royalty_tokens", + return_value=TX_HASH.hex(), + ) as mock_distribute, + ): + result = ip_asset.register_ip_and_attach_pil_terms_and_distribute_royalty_tokens( + nft_contract=ADDRESS, + token_id=3, + license_terms_data=LICENSE_TERMS_DATA, + royalty_shares=royalty_shares, + ) + + # Verify distribute was called with correct arguments + mock_distribute.assert_called_once() + call_kwargs = mock_distribute.call_args[1] + assert call_kwargs["ip_id"] == IP_ID + assert ( + call_kwargs["royalty_shares"] + == get_royalty_shares(royalty_shares)["royalty_shares"] + ) + assert call_kwargs["royalty_vault"] == ADDRESS + assert ( + call_kwargs["total_amount"] + == get_royalty_shares(royalty_shares)["total_amount"] + ) + + assert result["tx_hash"] == TX_HASH.hex() + assert result["ip_id"] == IP_ID + assert result["token_id"] == 3 + assert result["license_terms_ids"] == [1, 2] + assert result["royalty_vault"] == ADDRESS + assert result["distribute_royalty_tokens_tx_hash"] == TX_HASH.hex() + + def test_success_with_custom_values( + self, + ip_asset: IPAsset, + mock_get_ip_id, + mock_is_registered, + mock_parse_ip_registered_event, + mock_parse_tx_license_terms_attached_event, + mock_signature_related_methods, + mock_get_royalty_vault_address_by_ip_id, + mock_ip_account_impl_client, + ): + royalty_shares = [ + RoyaltyShareInput(recipient=ACCOUNT_ADDRESS, percentage=60.0), + ] + + with ( + mock_get_ip_id(), + mock_is_registered(), + mock_parse_ip_registered_event(), + mock_parse_tx_license_terms_attached_event(), + mock_signature_related_methods(), + mock_get_royalty_vault_address_by_ip_id(), + mock_ip_account_impl_client(), + ): + with ( + patch.object( + ip_asset.royalty_token_distribution_workflows_client, + "build_registerIpAndAttachPILTermsAndDeployRoyaltyVault_transaction", + return_value={"tx_hash": TX_HASH.hex()}, + ) as mock_build_transaction, + patch.object( + ip_asset, + "_distribute_royalty_tokens", + return_value=TX_HASH.hex(), + ), + ): + result = ip_asset.register_ip_and_attach_pil_terms_and_distribute_royalty_tokens( + nft_contract=ADDRESS, + token_id=3, + license_terms_data=LICENSE_TERMS_DATA, + royalty_shares=royalty_shares, + ip_metadata=IP_METADATA, + deadline=1000, + ) + + # Verify transaction was called with correct arguments + called_args = mock_build_transaction.call_args[0] + assert called_args[0] == ADDRESS + assert called_args[1] == 3 + assert ( + called_args[2] + == IPMetadata.from_input(IP_METADATA).get_validated_data() + ) + + assert result["tx_hash"] == TX_HASH.hex() + assert result["ip_id"] == IP_ID + assert result["token_id"] == 3 + assert result["license_terms_ids"] == [1, 2] + assert result["royalty_vault"] == ADDRESS + assert result["distribute_royalty_tokens_tx_hash"] == TX_HASH.hex() + + def test_throw_error_when_transaction_failed( + self, + ip_asset: IPAsset, + mock_get_ip_id, + mock_is_registered, + mock_signature_related_methods, + ): + with ( + mock_get_ip_id(), + mock_is_registered(), + mock_signature_related_methods(), + ): + with patch.object( + ip_asset.royalty_token_distribution_workflows_client, + "build_registerIpAndAttachPILTermsAndDeployRoyaltyVault_transaction", + side_effect=Exception("Transaction failed."), + ): + with pytest.raises( + ValueError, + match="Failed to register IP, attach PIL terms and distribute royalty tokens: Transaction failed.", + ): + ip_asset.register_ip_and_attach_pil_terms_and_distribute_royalty_tokens( + nft_contract=ADDRESS, + token_id=3, + license_terms_data=LICENSE_TERMS_DATA, + royalty_shares=[ + RoyaltyShareInput( + recipient=ACCOUNT_ADDRESS, percentage=50.0 + ) + ], + )