From c59699804447b42d84559acaf743f9398a89df15 Mon Sep 17 00:00:00 2001 From: Bonnie Date: Tue, 9 Dec 2025 10:57:03 +0800 Subject: [PATCH 01/21] feat: introduce PILFlavor class for managing programmable IP license terms --- src/story_protocol_python_sdk/__init__.py | 3 +- .../utils/pil_flavor.py | 464 ++++++++++++++++++ 2 files changed, 466 insertions(+), 1 deletion(-) create mode 100644 src/story_protocol_python_sdk/utils/pil_flavor.py diff --git a/src/story_protocol_python_sdk/__init__.py b/src/story_protocol_python_sdk/__init__.py index 4953f30..adc3f83 100644 --- a/src/story_protocol_python_sdk/__init__.py +++ b/src/story_protocol_python_sdk/__init__.py @@ -30,7 +30,7 @@ RegistrationWithRoyaltyVaultResponse, ) from .types.resource.License import LicenseTermsInput -from .types.resource.Royalty import RoyaltyShareInput +from .types.resource.Royalty import NativeRoyaltyPolicy, RoyaltyShareInput from .utils.constants import ( DEFAULT_FUNCTION_SELECTOR, MAX_ROYALTY_TOKEN, @@ -72,6 +72,7 @@ "LicensingConfig", "RegisterPILTermsAndAttachResponse", "RoyaltyShareInput", + "NativeRoyaltyPolicy", "LicenseTermsInput", "MintNFT", "MintedNFT", diff --git a/src/story_protocol_python_sdk/utils/pil_flavor.py b/src/story_protocol_python_sdk/utils/pil_flavor.py new file mode 100644 index 0000000..f45bc7b --- /dev/null +++ b/src/story_protocol_python_sdk/utils/pil_flavor.py @@ -0,0 +1,464 @@ +from typing import Optional, TypedDict + +from ens.ens import Address + +from story_protocol_python_sdk.types.resource.Royalty import RoyaltyPolicyInput +from story_protocol_python_sdk.utils.constants import ZERO_ADDRESS +from story_protocol_python_sdk.utils.royalty import royalty_policy_input_to_address + + +class LicenseTerms(TypedDict): + """ + The normalized license terms structure used internally by the SDK. + Uses camelCase keys to match the contract interface. + """ + + transferable: bool + royaltyPolicy: Address + defaultMintingFee: int + expiration: int + commercialUse: bool + commercialAttribution: bool + commercializerChecker: Address + commercializerCheckerData: str + commercialRevShare: int + commercialRevCeiling: int + derivativesAllowed: bool + derivativesAttribution: bool + derivativesApproval: bool + derivativesReciprocal: bool + derivativeRevCeiling: int + currency: Address + uri: str + + +class LicenseTermsOverride(TypedDict, total=False): + """ + Optional override parameters for license terms. + Uses snake_case keys following SDK conventions. + """ + + transferable: bool + """Whether the license is transferable.""" + royalty_policy: RoyaltyPolicyInput + """The type of royalty policy to be used.""" + default_minting_fee: int + """The fee to be paid when minting a license.""" + expiration: int + """The expiration period of the license.""" + commercial_use: bool + """Whether commercial use is allowed.""" + commercial_attribution: bool + """Whether commercial attribution is required.""" + commercializer_checker: Address + """The address of the commercializer checker contract.""" + commercializer_checker_data: str + """Percentage of revenue that must be shared with the licensor. Must be between 0 and 100.""" + commercial_rev_share: int + """Percentage of revenue that must be shared with the licensor.""" + commercial_rev_ceiling: int + """The maximum revenue that can be collected from commercial use.""" + derivatives_allowed: bool + """Whether derivatives are allowed.""" + derivatives_attribution: bool + """Whether attribution is required for derivatives.""" + derivatives_approval: bool + """Whether approval is required for derivatives.""" + derivatives_reciprocal: bool + """Whether derivatives must have the same license terms.""" + derivative_rev_ceiling: int + """The maximum revenue that can be collected from derivatives.""" + currency: Address + """The ERC20 token to be used to pay the minting fee.""" + uri: str + """The URI of the license terms.""" + + +class NonCommercialSocialRemixingRequest(TypedDict, total=False): + """Request parameters for non-commercial social remixing license.""" + + override: LicenseTermsOverride + """Optional overrides for the default license terms.""" + + +class CommercialUseRequest(TypedDict, total=False): + """Request parameters for commercial use license.""" + + default_minting_fee: int + """The fee to be paid when minting a license.""" + currency: Address + """The ERC20 token to be used to pay the minting fee.""" + royalty_policy: RoyaltyPolicyInput + """The type of royalty policy to be used. Default is LAP.""" + override: LicenseTermsOverride + """Optional overrides for the default license terms.""" + + +class CommercialRemixRequest(TypedDict, total=False): + """Request parameters for commercial remix license.""" + + default_minting_fee: int + """The fee to be paid when minting a license.""" + commercial_rev_share: int + """Percentage of revenue that must be shared with the licensor. Must be between 0 and 100.""" + currency: Address + """The ERC20 token to be used to pay the minting fee.""" + royalty_policy: RoyaltyPolicyInput + """The type of royalty policy to be used. Default is LAP.""" + override: LicenseTermsOverride + """Optional overrides for the default license terms.""" + + +class CreativeCommonsAttributionRequest(TypedDict, total=False): + """Request parameters for creative commons attribution license.""" + + currency: Address + """The ERC20 token to be used to pay the minting fee.""" + royalty_policy: RoyaltyPolicyInput + """The type of royalty policy to be used. Default is LAP.""" + override: LicenseTermsOverride + """Optional overrides for the default license terms.""" + + +# PIL URIs for off-chain terms +PIL_URIS = { + "NCSR": "https://github.com/piplabs/pil-document/blob/998c13e6ee1d04eb817aefd1fe16dfe8be3cd7a2/off-chain-terms/NCSR.json", + "COMMERCIAL_USE": "https://github.com/piplabs/pil-document/blob/9a1f803fcf8101a8a78f1dcc929e6014e144ab56/off-chain-terms/CommercialUse.json", + "COMMERCIAL_REMIX": "https://github.com/piplabs/pil-document/blob/ad67bb632a310d2557f8abcccd428e4c9c798db1/off-chain-terms/CommercialRemix.json", + "CC_BY": "https://github.com/piplabs/pil-document/blob/998c13e6ee1d04eb817aefd1fe16dfe8be3cd7a2/off-chain-terms/CC-BY.json", +} + +# Common default values for license terms +COMMON_DEFAULTS: LicenseTerms = { + "transferable": True, + "royaltyPolicy": ZERO_ADDRESS, + "defaultMintingFee": 0, + "expiration": 0, + "commercialUse": False, + "commercialAttribution": False, + "commercializerChecker": ZERO_ADDRESS, + "commercializerCheckerData": ZERO_ADDRESS, + "commercialRevShare": 0, + "commercialRevCeiling": 0, + "derivativesAllowed": False, + "derivativesAttribution": False, + "derivativesApproval": False, + "derivativesReciprocal": False, + "derivativeRevCeiling": 0, + "currency": ZERO_ADDRESS, + "uri": "", +} + + +class PILFlavorError(Exception): + """Exception for PIL flavor validation errors.""" + + pass + + +class PILFlavor: + """ + Pre-configured Programmable IP License (PIL) flavors for ease of use. + + The PIL is highly configurable, but these pre-configured license terms (flavors) + are the most popular options that cover common use cases. + + See: https://docs.story.foundation/concepts/programmable-ip-license/pil-flavors + + Example: + # Create a commercial use license + commercial_license = PILFlavor.commercial_use( + default_minting_fee=1000000000000000000, # 1 IP minting fee + currency="0x1234...", # currency token + royalty_policy="LAP" # royalty policy + ) + + # Create a non-commercial social remixing license + remix_license = PILFlavor.non_commercial_social_remixing() + """ + + _non_commercial_social_remixing_pil: LicenseTerms = { + **COMMON_DEFAULTS, + "commercialUse": False, + "commercialAttribution": False, + "derivativesAllowed": True, + "derivativesAttribution": True, + "derivativesApproval": False, + "derivativesReciprocal": True, + "uri": PIL_URIS["NCSR"], + } + + _commercial_use: LicenseTerms = { + **COMMON_DEFAULTS, + "commercialUse": True, + "commercialAttribution": True, + "derivativesAllowed": False, + "derivativesAttribution": False, + "derivativesApproval": False, + "derivativesReciprocal": False, + "uri": PIL_URIS["COMMERCIAL_USE"], + } + + _commercial_remix: LicenseTerms = { + **COMMON_DEFAULTS, + "commercialUse": True, + "commercialAttribution": True, + "derivativesAllowed": True, + "derivativesAttribution": True, + "derivativesApproval": False, + "derivativesReciprocal": True, + "uri": PIL_URIS["COMMERCIAL_REMIX"], + } + + _creative_commons_attribution: LicenseTerms = { + **COMMON_DEFAULTS, + "commercialUse": True, + "commercialAttribution": True, + "derivativesAllowed": True, + "derivativesAttribution": True, + "derivativesApproval": False, + "derivativesReciprocal": True, + "uri": PIL_URIS["CC_BY"], + } + + # Mapping from snake_case to camelCase for license terms + _OVERRIDE_KEY_MAP = { + "transferable": "transferable", + "royalty_policy": "royaltyPolicy", + "default_minting_fee": "defaultMintingFee", + "expiration": "expiration", + "commercial_use": "commercialUse", + "commercial_attribution": "commercialAttribution", + "commercializer_checker": "commercializerChecker", + "commercializer_checker_data": "commercializerCheckerData", + "commercial_rev_share": "commercialRevShare", + "commercial_rev_ceiling": "commercialRevCeiling", + "derivatives_allowed": "derivativesAllowed", + "derivatives_attribution": "derivativesAttribution", + "derivatives_approval": "derivativesApproval", + "derivatives_reciprocal": "derivativesReciprocal", + "derivative_rev_ceiling": "derivativeRevCeiling", + "currency": "currency", + "uri": "uri", + } + + @staticmethod + def _convert_override_to_camel_case(override: LicenseTermsOverride) -> dict: + """Convert snake_case override keys to camelCase for internal use.""" + result = {} + for key, value in override.items(): + camel_key = PILFlavor._OVERRIDE_KEY_MAP.get(key) + if camel_key: + result[camel_key] = value + return result + + @staticmethod + def non_commercial_social_remixing( + override: Optional[LicenseTermsOverride] = None, + ) -> LicenseTerms: + """ + Gets the values to create a Non-Commercial Social Remixing license terms flavor. + + See: https://docs.story.foundation/concepts/programmable-ip-license/pil-flavors#non-commercial-social-remixing + + :param override: Optional overrides for the default license terms. + :return: The license terms dictionary. + """ + terms = {**PILFlavor._non_commercial_social_remixing_pil} + if override: + terms.update(PILFlavor._convert_override_to_camel_case(override)) + return PILFlavor.validate_license_terms(terms) + + @staticmethod + def commercial_use( + default_minting_fee: int, + currency: Address, + royalty_policy: Optional[RoyaltyPolicyInput] = None, + override: Optional[LicenseTermsOverride] = None, + ) -> LicenseTerms: + """ + Gets the values to create a Commercial Use license terms flavor. + + See: https://docs.story.foundation/concepts/programmable-ip-license/pil-flavors#commercial-use + + :param default_minting_fee: The fee to be paid when minting a license. + :param currency: The ERC20 token to be used to pay the minting fee. + :param royalty_policy: The type of royalty policy to be used. Default is LAP. + :param override: Optional overrides for the default license terms. + :return: The license terms dictionary. + """ + terms = { + **PILFlavor._commercial_use, + "defaultMintingFee": default_minting_fee, + "currency": currency, + "royaltyPolicy": royalty_policy, + } + if override: + terms.update(PILFlavor._convert_override_to_camel_case(override)) + return PILFlavor.validate_license_terms(terms) + + @staticmethod + def commercial_remix( + default_minting_fee: int, + currency: Address, + commercial_rev_share: int, + royalty_policy: Optional[RoyaltyPolicyInput] = None, + override: Optional[LicenseTermsOverride] = None, + ) -> LicenseTerms: + """ + Gets the values to create a Commercial Remixing license terms flavor. + + See: https://docs.story.foundation/concepts/programmable-ip-license/pil-flavors#commercial-remix + + :param default_minting_fee: The fee to be paid when minting a license. + :param currency: The ERC20 token to be used to pay the minting fee. + :param commercial_rev_share: Percentage of revenue that must be shared with the licensor. Must be between 0 and 100. + :param royalty_policy: The type of royalty policy to be used. Default is LAP. + :param override: Optional overrides for the default license terms. + :return: The license terms dictionary. + """ + terms = { + **PILFlavor._commercial_remix, + "defaultMintingFee": default_minting_fee, + "currency": currency, + "commercialRevShare": commercial_rev_share, + "royaltyPolicy": royalty_policy, + } + if override: + terms.update(PILFlavor._convert_override_to_camel_case(override)) + return PILFlavor.validate_license_terms(terms) + + @staticmethod + def creative_commons_attribution( + currency: Address, + royalty_policy: Optional[RoyaltyPolicyInput] = None, + override: Optional[LicenseTermsOverride] = None, + ) -> LicenseTerms: + """ + Gets the values to create a Creative Commons Attribution (CC-BY) license terms flavor. + + See: https://docs.story.foundation/concepts/programmable-ip-license/pil-flavors#creative-commons-attribution + + :param currency: The ERC20 token to be used to pay the minting fee. + :param royalty_policy: The type of royalty policy to be used. Default is LAP. + :param override: Optional overrides for the default license terms. + :return: The license terms dictionary. + """ + terms = { + **PILFlavor._creative_commons_attribution, + "currency": currency, + "royaltyPolicy": royalty_policy, + } + if override: + terms.update(PILFlavor._convert_override_to_camel_case(override)) + return PILFlavor.validate_license_terms(terms) + + @staticmethod + def validate_license_terms(params: dict) -> LicenseTerms: + """ + Validates and normalizes license terms. + + :param params: The license terms parameters to validate. + :return: The validated and normalized license terms. + :raises PILFlavorError: If validation fails. + """ + normalized: LicenseTerms = { + "transferable": params.get("transferable", True), + "royaltyPolicy": royalty_policy_input_to_address( + params.get("royaltyPolicy") + ), + "defaultMintingFee": int(params.get("defaultMintingFee", 0)), + "expiration": int(params.get("expiration", 0)), + "commercialUse": params.get("commercialUse", False), + "commercialAttribution": params.get("commercialAttribution", False), + "commercializerChecker": params.get("commercializerChecker", ZERO_ADDRESS), + "commercializerCheckerData": params.get( + "commercializerCheckerData", ZERO_ADDRESS + ), + "commercialRevShare": params.get("commercialRevShare", 0), + "commercialRevCeiling": int(params.get("commercialRevCeiling", 0)), + "derivativesAllowed": params.get("derivativesAllowed", False), + "derivativesAttribution": params.get("derivativesAttribution", False), + "derivativesApproval": params.get("derivativesApproval", False), + "derivativesReciprocal": params.get("derivativesReciprocal", False), + "derivativeRevCeiling": int(params.get("derivativeRevCeiling", 0)), + "currency": params.get("currency", ZERO_ADDRESS), + "uri": params.get("uri", ""), + } + + royalty_policy = normalized["royaltyPolicy"] + currency = normalized["currency"] + + # Validate royalty policy and currency relationship + if royalty_policy != ZERO_ADDRESS and currency == ZERO_ADDRESS: + raise PILFlavorError("royalty policy requires currency token.") + + # Validate defaultMintingFee + if normalized["defaultMintingFee"] < 0: + raise PILFlavorError( + "defaultMintingFee should be greater than or equal to 0." + ) + + if ( + normalized["defaultMintingFee"] > 0 + and normalized["royaltyPolicy"] == ZERO_ADDRESS + ): + raise PILFlavorError( + "royalty policy is required when defaultMintingFee is greater than 0." + ) + + # Validate commercial use and derivatives + PILFlavor._verify_commercial_use(normalized) + PILFlavor._verify_derivatives(normalized) + + if ( + normalized["commercialRevShare"] > 100 + or normalized["commercialRevShare"] < 0 + ): + raise PILFlavorError("commercialRevShare must be between 0 and 100.") + + return normalized + + @staticmethod + def _verify_commercial_use(terms: LicenseTerms) -> None: + """Verify commercial use related fields.""" + if not terms["commercialUse"]: + commercial_fields = [ + ("commercialAttribution", terms["commercialAttribution"]), + ( + "commercializerChecker", + terms["commercializerChecker"] != ZERO_ADDRESS, + ), + ("commercialRevShare", terms["commercialRevShare"] > 0), + ("commercialRevCeiling", terms["commercialRevCeiling"] > 0), + ("derivativeRevCeiling", terms["derivativeRevCeiling"] > 0), + ("royaltyPolicy", terms["royaltyPolicy"] != ZERO_ADDRESS), + ] + + for field, value in commercial_fields: + if value: + raise PILFlavorError( + f"cannot add {field} when commercial use is disabled." + ) + else: + if terms["royaltyPolicy"] == ZERO_ADDRESS: + raise PILFlavorError( + "royalty policy is required when commercial use is enabled." + ) + + @staticmethod + def _verify_derivatives(terms: LicenseTerms) -> None: + """Verify derivatives related fields.""" + if not terms["derivativesAllowed"]: + derivative_fields = [ + ("derivativesAttribution", terms["derivativesAttribution"]), + ("derivativesApproval", terms["derivativesApproval"]), + ("derivativesReciprocal", terms["derivativesReciprocal"]), + ("derivativeRevCeiling", terms["derivativeRevCeiling"] > 0), + ] + + for field, value in derivative_fields: + if value: + raise PILFlavorError( + f"cannot add {field} when derivative use is disabled." + ) From 3f48bf7777a301a6f7e267636443bd6458149dd6 Mon Sep 17 00:00:00 2001 From: Bonnie Date: Tue, 9 Dec 2025 16:18:22 +0800 Subject: [PATCH 02/21] feat: enhance PILFlavor class with camelCase to snake_case conversion and improve error messages for clarity --- src/story_protocol_python_sdk/__init__.py | 3 +++ .../utils/pil_flavor.py | 24 +++++++++++++------ 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/src/story_protocol_python_sdk/__init__.py b/src/story_protocol_python_sdk/__init__.py index adc3f83..a08ecd6 100644 --- a/src/story_protocol_python_sdk/__init__.py +++ b/src/story_protocol_python_sdk/__init__.py @@ -44,6 +44,7 @@ from .utils.derivative_data import DerivativeDataInput from .utils.ip_metadata import IPMetadataInput from .utils.licensing_config_data import LicensingConfig +from .utils.pil_flavor import PILFlavor __all__ = [ "StoryClient", @@ -87,4 +88,6 @@ "DEFAULT_FUNCTION_SELECTOR", "MAX_ROYALTY_TOKEN", "WIP_TOKEN_ADDRESS", + # utils + "PILFlavor", ] diff --git a/src/story_protocol_python_sdk/utils/pil_flavor.py b/src/story_protocol_python_sdk/utils/pil_flavor.py index f45bc7b..ebc0289 100644 --- a/src/story_protocol_python_sdk/utils/pil_flavor.py +++ b/src/story_protocol_python_sdk/utils/pil_flavor.py @@ -252,6 +252,14 @@ def _convert_override_to_camel_case(override: LicenseTermsOverride) -> dict: result[camel_key] = value return result + @staticmethod + def _convert_camel_case_to_snake_case(camel_case_key: str) -> str: + """Convert camelCase to snake_case for internal use.""" + for key, value in PILFlavor._OVERRIDE_KEY_MAP.items(): + if value == camel_case_key: + return key + raise ValueError(f"Unknown camelCase key: {camel_case_key}") # pragma: no cover + @staticmethod def non_commercial_social_remixing( override: Optional[LicenseTermsOverride] = None, @@ -391,12 +399,14 @@ def validate_license_terms(params: dict) -> LicenseTerms: # Validate royalty policy and currency relationship if royalty_policy != ZERO_ADDRESS and currency == ZERO_ADDRESS: - raise PILFlavorError("royalty policy requires currency token.") + raise PILFlavorError( + "royalty_policy is not zero address and currency cannot be zero address." + ) # Validate defaultMintingFee if normalized["defaultMintingFee"] < 0: raise PILFlavorError( - "defaultMintingFee should be greater than or equal to 0." + "default_minting_fee should be greater than or equal to 0." ) if ( @@ -404,7 +414,7 @@ def validate_license_terms(params: dict) -> LicenseTerms: and normalized["royaltyPolicy"] == ZERO_ADDRESS ): raise PILFlavorError( - "royalty policy is required when defaultMintingFee is greater than 0." + "royalty_policy is required when default_minting_fee is greater than 0." ) # Validate commercial use and derivatives @@ -415,7 +425,7 @@ def validate_license_terms(params: dict) -> LicenseTerms: normalized["commercialRevShare"] > 100 or normalized["commercialRevShare"] < 0 ): - raise PILFlavorError("commercialRevShare must be between 0 and 100.") + raise PILFlavorError("commercial_rev_share must be between 0 and 100.") return normalized @@ -438,12 +448,12 @@ def _verify_commercial_use(terms: LicenseTerms) -> None: for field, value in commercial_fields: if value: raise PILFlavorError( - f"cannot add {field} when commercial use is disabled." + f"cannot add {PILFlavor._convert_camel_case_to_snake_case(field)} when commercial_use is False." ) else: if terms["royaltyPolicy"] == ZERO_ADDRESS: raise PILFlavorError( - "royalty policy is required when commercial use is enabled." + "royalty_policy is required when commercial_use is True." ) @staticmethod @@ -460,5 +470,5 @@ def _verify_derivatives(terms: LicenseTerms) -> None: for field, value in derivative_fields: if value: raise PILFlavorError( - f"cannot add {field} when derivative use is disabled." + f"cannot add {PILFlavor._convert_camel_case_to_snake_case(field)} when derivatives_allowed is False." ) From 1d5f518d9cab58874cf643a1481fdd86f0e83e9d Mon Sep 17 00:00:00 2001 From: Bonnie Date: Tue, 9 Dec 2025 16:18:42 +0800 Subject: [PATCH 03/21] feat: add unit tests for PILFlavor class to validate non-commercial and commercial use cases --- tests/unit/utils/test_pil_flavor.py | 523 ++++++++++++++++++++++++++++ 1 file changed, 523 insertions(+) create mode 100644 tests/unit/utils/test_pil_flavor.py diff --git a/tests/unit/utils/test_pil_flavor.py b/tests/unit/utils/test_pil_flavor.py new file mode 100644 index 0000000..3ba4363 --- /dev/null +++ b/tests/unit/utils/test_pil_flavor.py @@ -0,0 +1,523 @@ +import pytest + +from story_protocol_python_sdk import ( + ROYALTY_POLICY_LAP_ADDRESS, + ROYALTY_POLICY_LRP_ADDRESS, + WIP_TOKEN_ADDRESS, + ZERO_ADDRESS, + NativeRoyaltyPolicy, + PILFlavor, +) +from story_protocol_python_sdk.utils.pil_flavor import PILFlavorError +from tests.unit.fixtures.data import ADDRESS + + +class TestPILFlavor: + """Test PILFlavor class.""" + + class TestNonCommercialSocialRemixing: + """Test non commercial social remixing PIL flavor.""" + + def test_default_values(self): + """Test default values.""" + pil_flavor = PILFlavor.non_commercial_social_remixing() + assert pil_flavor == { + "transferable": True, + "commercialAttribution": False, + "commercialRevCeiling": 0, + "commercialRevShare": 0, + "commercialUse": False, + "commercializerChecker": ZERO_ADDRESS, + "commercializerCheckerData": ZERO_ADDRESS, + "currency": ZERO_ADDRESS, + "derivativeRevCeiling": 0, + "derivativesAllowed": True, + "derivativesApproval": False, + "derivativesAttribution": True, + "derivativesReciprocal": True, + "expiration": 0, + "defaultMintingFee": 0, + "royaltyPolicy": ZERO_ADDRESS, + "uri": "https://github.com/piplabs/pil-document/blob/998c13e6ee1d04eb817aefd1fe16dfe8be3cd7a2/off-chain-terms/NCSR.json", + } + + def test_override_values(self): + """Test override values.""" + pil_flavor = PILFlavor.non_commercial_social_remixing( + override={ + "commercial_use": True, + "commercial_attribution": True, + "royalty_policy": NativeRoyaltyPolicy.LAP, + "currency": WIP_TOKEN_ADDRESS, + } + ) + assert pil_flavor == { + "transferable": True, + "commercialAttribution": True, + "commercialRevCeiling": 0, + "commercialRevShare": 0, + "commercialUse": True, + "commercializerChecker": ZERO_ADDRESS, + "commercializerCheckerData": ZERO_ADDRESS, + "currency": WIP_TOKEN_ADDRESS, + "derivativeRevCeiling": 0, + "derivativesAllowed": True, + "derivativesApproval": False, + "derivativesAttribution": True, + "derivativesReciprocal": True, + "expiration": 0, + "defaultMintingFee": 0, + "royaltyPolicy": ROYALTY_POLICY_LAP_ADDRESS, + "uri": "https://github.com/piplabs/pil-document/blob/998c13e6ee1d04eb817aefd1fe16dfe8be3cd7a2/off-chain-terms/NCSR.json", + } + + def test_throw_commercial_attribution_error_when_commercial_use_is_false(self): + """Test throw commercial attribution error when commercial use is false.""" + with pytest.raises( + PILFlavorError, + match="cannot add commercial_attribution when commercial_use is False.", + ): + PILFlavor.non_commercial_social_remixing( + override={"commercial_attribution": True}, + ) + + def test_throw_commercializer_checker_error_when_commercial_use_is_false(self): + """Test throw commercializer checker error when commercial use is false.""" + with pytest.raises( + PILFlavorError, + match="cannot add commercializer_checker when commercial_use is False.", + ): + PILFlavor.non_commercial_social_remixing( + override={"commercializer_checker": ADDRESS}, + ) + + def test_throw_commercial_rev_share_error_when_commercial_use_is_false(self): + """Test throw commercial rev share error when commercial use is false.""" + with pytest.raises( + PILFlavorError, + match="cannot add commercial_rev_share when commercial_use is False.", + ): + PILFlavor.non_commercial_social_remixing( + override={"commercial_rev_share": 10}, + ) + + def test_throw_commercial_rev_ceiling_error_when_commercial_use_is_false(self): + """Test throw commercial rev ceiling error when commercial use is false.""" + with pytest.raises( + PILFlavorError, + match="cannot add commercial_rev_ceiling when commercial_use is False.", + ): + PILFlavor.non_commercial_social_remixing( + override={"commercial_rev_ceiling": 10000}, + ) + + def test_throw_derivative_rev_ceiling_error_when_commercial_use_is_false(self): + """Test throw derivative rev ceiling error when commercial use is false.""" + with pytest.raises( + PILFlavorError, + match="cannot add derivative_rev_ceiling when commercial_use is False.", + ): + PILFlavor.non_commercial_social_remixing( + override={"derivative_rev_ceiling": 10000}, + ) + + def test_throw_royalty_policy_error_when_commercial_use_is_false(self): + """Test throw royalty policy error when commercial use is false.""" + with pytest.raises( + PILFlavorError, + match="cannot add royalty_policy when commercial_use is False.", + ): + PILFlavor.non_commercial_social_remixing( + override={"royalty_policy": ADDRESS, "currency": WIP_TOKEN_ADDRESS}, + ) + + class TestCommercialUse: + """Test commercial use PIL flavor.""" + + def test_default_values(self): + """Test default values.""" + pil_flavor = PILFlavor.commercial_use( + default_minting_fee=10000, + currency=WIP_TOKEN_ADDRESS, + royalty_policy=ADDRESS, + ) + assert pil_flavor == { + "transferable": True, + "commercialAttribution": True, + "commercialRevCeiling": 0, + "commercialRevShare": 0, + "commercialUse": True, + "commercializerChecker": ZERO_ADDRESS, + "commercializerCheckerData": ZERO_ADDRESS, + "currency": WIP_TOKEN_ADDRESS, + "derivativeRevCeiling": 0, + "derivativesAllowed": False, + "derivativesApproval": False, + "derivativesAttribution": False, + "derivativesReciprocal": False, + "expiration": 0, + "defaultMintingFee": 10000, + "royaltyPolicy": ADDRESS, + "uri": "https://github.com/piplabs/pil-document/blob/9a1f803fcf8101a8a78f1dcc929e6014e144ab56/off-chain-terms/CommercialUse.json", + } + + def test_without_royalty_policy(self): + """Test without royalty policy.""" + pil_flavor = PILFlavor.commercial_use( + default_minting_fee=10000, + currency=WIP_TOKEN_ADDRESS, + ) + assert pil_flavor == { + "transferable": True, + "commercialAttribution": True, + "commercialRevCeiling": 0, + "commercialRevShare": 0, + "commercialUse": True, + "commercializerChecker": ZERO_ADDRESS, + "commercializerCheckerData": ZERO_ADDRESS, + "currency": WIP_TOKEN_ADDRESS, + "derivativeRevCeiling": 0, + "derivativesAllowed": False, + "derivativesApproval": False, + "derivativesAttribution": False, + "derivativesReciprocal": False, + "expiration": 0, + "defaultMintingFee": 10000, + "royaltyPolicy": ROYALTY_POLICY_LAP_ADDRESS, + "uri": "https://github.com/piplabs/pil-document/blob/9a1f803fcf8101a8a78f1dcc929e6014e144ab56/off-chain-terms/CommercialUse.json", + } + + def test_with_custom_values(self): + """Test with custom values.""" + pil_flavor = PILFlavor.commercial_use( + default_minting_fee=10000, + currency=WIP_TOKEN_ADDRESS, + royalty_policy=ADDRESS, + override={ + "commercial_attribution": False, + "derivatives_allowed": True, + "derivatives_attribution": True, + "derivatives_approval": False, + "derivatives_reciprocal": True, + "uri": "https://example.com", + "royalty_policy": NativeRoyaltyPolicy.LRP, + "default_minting_fee": 10, + "commercial_rev_share": 10, + }, + ) + assert pil_flavor == { + "transferable": True, + "commercialAttribution": False, + "commercialRevCeiling": 0, + "commercialRevShare": 10, + "commercialUse": True, + "commercializerChecker": ZERO_ADDRESS, + "commercializerCheckerData": ZERO_ADDRESS, + "currency": WIP_TOKEN_ADDRESS, + "derivativeRevCeiling": 0, + "derivativesAllowed": True, + "derivativesApproval": False, + "derivativesAttribution": True, + "derivativesReciprocal": True, + "expiration": 0, + "defaultMintingFee": 10, + "royaltyPolicy": ROYALTY_POLICY_LRP_ADDRESS, + "uri": "https://example.com", + } + + def test_throw_error_when_royalty_policy_is_not_zero_address_and_currency_is_zero_address( + self, + ): + """Test throw error when royalty policy is not zero address and currency is zero address.""" + with pytest.raises( + PILFlavorError, + match="royalty_policy is not zero address and currency cannot be zero address.", + ): + PILFlavor.commercial_use( + default_minting_fee=10000, + currency=ZERO_ADDRESS, + royalty_policy=ADDRESS, + ) + + def test_throw_error_when_default_minting_fee_is_less_than_zero(self): + """Test throw error when default minting fee is less than zero.""" + with pytest.raises( + PILFlavorError, + match="default_minting_fee should be greater than or equal to 0.", + ): + PILFlavor.commercial_use( + default_minting_fee=-1, + currency=WIP_TOKEN_ADDRESS, + royalty_policy=ADDRESS, + ) + + def test_not_throw_error_when_default_minting_fee_is_zero_and_royalty_policy_is_not_zero_address( + self, + ): + """Test not throw error when default minting fee is zero and royalty policy is not zero address.""" + pil_flavor = PILFlavor.commercial_use( + default_minting_fee=0, + currency=WIP_TOKEN_ADDRESS, + royalty_policy=ADDRESS, + ) + assert pil_flavor.get("defaultMintingFee") == 0 + + def test_not_throw_error_when_default_minting_fee_is_100_(self): + """Test not throw error when default minting fee is 100""" + pil_flavor = PILFlavor.commercial_use( + default_minting_fee=100, + currency=WIP_TOKEN_ADDRESS, + royalty_policy=ADDRESS, + ) + assert pil_flavor.get("defaultMintingFee") == 100 + + def test_throw_error_when_default_minting_fee_is_greater_than_zero_and_royalty_policy_is_zero_address( + self, + ): + """Test throw error when default minting fee is greater than zero and royalty policy is zero address.""" + with pytest.raises( + PILFlavorError, + match="royalty_policy is required when default_minting_fee is greater than 0.", + ): + PILFlavor.commercial_use( + default_minting_fee=10000, + currency=WIP_TOKEN_ADDRESS, + royalty_policy=ZERO_ADDRESS, + ) + + def test_throw_error_when_commercial_rev_share_is_less_than_zero(self): + """Test throw error when commercial rev share is less than zero.""" + with pytest.raises( + PILFlavorError, match="commercial_rev_share must be between 0 and 100." + ): + PILFlavor.commercial_use( + default_minting_fee=10000, + currency=WIP_TOKEN_ADDRESS, + royalty_policy=ADDRESS, + override={"commercial_rev_share": -1}, + ) + + def test_throw_error_when_commercial_rev_share_is_greater_than_100(self): + """Test throw error when commercial rev share is greater than 100.""" + with pytest.raises( + PILFlavorError, match="commercial_rev_share must be between 0 and 100." + ): + PILFlavor.commercial_use( + default_minting_fee=10000, + currency=WIP_TOKEN_ADDRESS, + royalty_policy=ADDRESS, + override={"commercial_rev_share": 101}, + ) + + def test_throw_error_when_commercial_is_true_and_royalty_policy_is_zero_address( + self, + ): + """Test throw error when commercial is true and royalty policy is zero address.""" + with pytest.raises( + PILFlavorError, + match="royalty_policy is required when commercial_use is True.", + ): + PILFlavor.commercial_use( + default_minting_fee=0, + currency=WIP_TOKEN_ADDRESS, + royalty_policy=ZERO_ADDRESS, + ) + + class TestCommercialRemix: + """Test commercial remix PIL flavor.""" + + def test_default_values(self): + """Test default values.""" + pil_flavor = PILFlavor.commercial_remix( + default_minting_fee=10000, + currency=WIP_TOKEN_ADDRESS, + commercial_rev_share=10, + ) + assert pil_flavor == { + "commercialAttribution": True, + "commercialRevCeiling": 0, + "commercialRevShare": 10, + "commercialUse": True, + "commercializerChecker": ZERO_ADDRESS, + "commercializerCheckerData": ZERO_ADDRESS, + "currency": WIP_TOKEN_ADDRESS, + "transferable": True, + "derivativeRevCeiling": 0, + "derivativesAllowed": True, + "derivativesApproval": False, + "derivativesAttribution": True, + "derivativesReciprocal": True, + "expiration": 0, + "defaultMintingFee": 10000, + "royaltyPolicy": ROYALTY_POLICY_LAP_ADDRESS, + "uri": "https://github.com/piplabs/pil-document/blob/ad67bb632a310d2557f8abcccd428e4c9c798db1/off-chain-terms/CommercialRemix.json", + } + + def test_with_custom_values(self): + """Test with custom values.""" + pil_flavor = PILFlavor.commercial_remix( + default_minting_fee=10000, + currency=WIP_TOKEN_ADDRESS, + commercial_rev_share=100, + override={ + "commercial_attribution": False, + "derivatives_allowed": True, + "derivatives_attribution": True, + "derivatives_approval": False, + "derivatives_reciprocal": True, + "uri": "https://example.com", + "royalty_policy": NativeRoyaltyPolicy.LRP, + "default_minting_fee": 10, + "commercial_rev_share": 10, + }, + ) + assert pil_flavor == { + "transferable": True, + "commercialAttribution": False, + "commercialRevCeiling": 0, + "derivativeRevCeiling": 0, + "derivativesAllowed": True, + "derivativesApproval": False, + "derivativesAttribution": True, + "derivativesReciprocal": True, + "currency": WIP_TOKEN_ADDRESS, + "commercialRevShare": 10, + "commercialUse": True, + "commercializerChecker": ZERO_ADDRESS, + "commercializerCheckerData": ZERO_ADDRESS, + "expiration": 0, + "defaultMintingFee": 10, + "royaltyPolicy": ROYALTY_POLICY_LRP_ADDRESS, + "uri": "https://example.com", + } + + class TestCreativeCommonsAttribution: + """Test creative commons attribution PIL flavor.""" + + def test_default_values(self): + """Test default values.""" + pil_flavor = PILFlavor.creative_commons_attribution( + currency=WIP_TOKEN_ADDRESS, + ) + assert pil_flavor == { + "transferable": True, + "commercialAttribution": True, + "commercialRevCeiling": 0, + "commercialRevShare": 0, + "commercialUse": True, + "commercializerChecker": ZERO_ADDRESS, + "commercializerCheckerData": ZERO_ADDRESS, + "currency": WIP_TOKEN_ADDRESS, + "derivativeRevCeiling": 0, + "derivativesAllowed": True, + "derivativesApproval": False, + "derivativesAttribution": True, + "derivativesReciprocal": True, + "expiration": 0, + "defaultMintingFee": 0, + "royaltyPolicy": ROYALTY_POLICY_LAP_ADDRESS, + "uri": "https://github.com/piplabs/pil-document/blob/998c13e6ee1d04eb817aefd1fe16dfe8be3cd7a2/off-chain-terms/CC-BY.json", + } + + def test_with_custom_values(self): + """Test with custom values.""" + pil_flavor = PILFlavor.creative_commons_attribution( + currency=WIP_TOKEN_ADDRESS, + override={ + "commercial_attribution": False, + "derivatives_allowed": True, + "derivatives_attribution": True, + "derivatives_approval": False, + "derivatives_reciprocal": True, + "uri": "https://example.com", + "royalty_policy": ADDRESS, + }, + ) + assert pil_flavor == { + "transferable": True, + "commercialAttribution": False, + "commercialRevCeiling": 0, + "commercialRevShare": 0, + "commercialUse": True, + "commercializerChecker": ZERO_ADDRESS, + "commercializerCheckerData": ZERO_ADDRESS, + "currency": WIP_TOKEN_ADDRESS, + "derivativeRevCeiling": 0, + "derivativesAllowed": True, + "derivativesApproval": False, + "derivativesAttribution": True, + "derivativesReciprocal": True, + "expiration": 0, + "defaultMintingFee": 0, + "royaltyPolicy": ADDRESS, + "uri": "https://example.com", + } + + def test_throw_derivatives_attribution_error_when_derivatives_allowed_is_false( + self, + ): + """Test throw derivatives attribution error when derivatives allowed is false.""" + with pytest.raises( + PILFlavorError, + match="cannot add derivatives_attribution when derivatives_allowed is False.", + ): + PILFlavor.creative_commons_attribution( + currency=WIP_TOKEN_ADDRESS, + override={ + "derivatives_allowed": False, + }, + ) + + def test_throw_derivatives_approval_error_when_derivatives_allowed_is_false( + self, + ): + """Test throw derivatives approval error when derivatives allowed is false.""" + with pytest.raises( + PILFlavorError, + match="cannot add derivatives_approval when derivatives_allowed is False.", + ): + PILFlavor.creative_commons_attribution( + currency=WIP_TOKEN_ADDRESS, + override={ + "derivatives_allowed": False, + "derivatives_approval": True, + "derivatives_attribution": False, + }, + ) + + def test_throw_derivatives_reciprocal_error_when_derivatives_allowed_is_false( + self, + ): + """Test throw derivatives reciprocal error when derivatives allowed is false.""" + with pytest.raises( + PILFlavorError, + match="cannot add derivatives_reciprocal when derivatives_allowed is False.", + ): + PILFlavor.creative_commons_attribution( + currency=WIP_TOKEN_ADDRESS, + override={ + "derivatives_allowed": False, + "derivatives_reciprocal": True, + "derivatives_attribution": False, + "derivatives_approval": False, + }, + ) + + def test_throw_derivative_rev_ceiling_error_when_derivatives_allowed_is_false( + self, + ): + """Test throw derivative rev ceiling error when derivatives allowed is false.""" + with pytest.raises( + PILFlavorError, + match="cannot add derivative_rev_ceiling when derivatives_allowed is False.", + ): + PILFlavor.creative_commons_attribution( + currency=WIP_TOKEN_ADDRESS, + override={ + "derivatives_allowed": False, + "derivative_rev_ceiling": 10000, + "derivatives_attribution": False, + "derivatives_approval": False, + "derivatives_reciprocal": False, + }, + ) From 0712ed8bd6df358d702d72c67f4511faed9fe87e Mon Sep 17 00:00:00 2001 From: Bonnie Date: Tue, 9 Dec 2025 16:57:09 +0800 Subject: [PATCH 04/21] feat: refactor License class to utilize PILFlavor for license terms management and enhance validation logic --- .../resources/License.py | 216 ++++++++---------- .../utils/pil_flavor.py | 3 +- 2 files changed, 101 insertions(+), 118 deletions(-) diff --git a/src/story_protocol_python_sdk/resources/License.py b/src/story_protocol_python_sdk/resources/License.py index 0abe68c..acb373f 100644 --- a/src/story_protocol_python_sdk/resources/License.py +++ b/src/story_protocol_python_sdk/resources/License.py @@ -1,4 +1,7 @@ +from typing import cast + from ens.ens import Address, HexStr +from typing_extensions import deprecated from web3 import Web3 from story_protocol_python_sdk.abi.IPAssetRegistry.IPAssetRegistry_client import ( @@ -16,13 +19,16 @@ from story_protocol_python_sdk.abi.PILicenseTemplate.PILicenseTemplate_client import ( PILicenseTemplateClient, ) +from story_protocol_python_sdk.abi.RoyaltyModule.RoyaltyModule_client import ( + RoyaltyModuleClient, +) from story_protocol_python_sdk.types.common import RevShareType from story_protocol_python_sdk.utils.constants import ZERO_ADDRESS -from story_protocol_python_sdk.utils.license_terms import LicenseTerms from story_protocol_python_sdk.utils.licensing_config_data import ( LicensingConfig, LicensingConfigData, ) +from story_protocol_python_sdk.utils.pil_flavor import LicenseTerms, PILFlavor from story_protocol_python_sdk.utils.transaction_utils import build_and_send_transaction from story_protocol_python_sdk.utils.validation import ( get_revenue_share, @@ -49,14 +55,13 @@ def __init__(self, web3: Web3, account, chain_id: int): self.licensing_module_client = LicensingModuleClient(web3) self.ip_asset_registry_client = IPAssetRegistryClient(web3) self.module_registry_client = ModuleRegistryClient(web3) + self.royalty_module_client = RoyaltyModuleClient(web3) - self.license_terms_util = LicenseTerms(web3) - - def _get_license_terms_id(self, license_terms: dict) -> int: + def _get_license_terms_id(self, license_terms: LicenseTerms) -> int: """ Get the ID of the license terms. - :param license_terms dict: The license terms. + :param license_terms LicenseTerms: The license terms. :return int: The ID of the license terms. """ return self.license_template_client.getLicenseTermsId(license_terms) @@ -106,48 +111,34 @@ def register_pil_terms( :return dict: A dictionary with the transaction hash and license terms ID. """ try: - license_terms = self.license_terms_util.validate_license_terms( - { - "transferable": transferable, - "royalty_policy": royalty_policy, - "default_minting_fee": default_minting_fee, - "expiration": expiration, - "commercial_use": commercial_use, - "commercial_attribution": commercial_attribution, - "commercializer_checker": commercializer_checker, - "commercializer_checker_data": commercializer_checker_data, - "commercial_rev_share": commercial_rev_share, - "commercial_rev_ceiling": commercial_rev_ceiling, - "derivatives_allowed": derivatives_allowed, - "derivatives_attribution": derivatives_attribution, - "derivatives_approval": derivatives_approval, - "derivatives_reciprocal": derivatives_reciprocal, - "derivative_rev_ceiling": derivative_rev_ceiling, - "currency": currency, - "uri": uri, - } - ) - - license_terms_id = self._get_license_terms_id(license_terms) - if (license_terms_id is not None) and (license_terms_id != 0): - return {"license_terms_id": license_terms_id} - - response = build_and_send_transaction( - self.web3, - self.account, - self.license_template_client.build_registerLicenseTerms_transaction, - license_terms, + return self._register_license_terms_helper( + license_terms=LicenseTerms( + transferable=transferable, + royaltyPolicy=royalty_policy, + defaultMintingFee=default_minting_fee, + expiration=expiration, + commercialUse=commercial_use, + commercialAttribution=commercial_attribution, + commercializerChecker=commercializer_checker, + commercializerCheckerData=commercializer_checker_data, + commercialRevShare=commercial_rev_share, + commercialRevCeiling=commercial_rev_ceiling, + derivativesAllowed=derivatives_allowed, + derivativesAttribution=derivatives_attribution, + derivativesApproval=derivatives_approval, + derivativesReciprocal=derivatives_reciprocal, + derivativeRevCeiling=derivative_rev_ceiling, + currency=currency, + uri=uri, + ), tx_options=tx_options, ) - - target_logs = self._parse_tx_license_terms_registered_event( - response["tx_receipt"] - ) - return {"tx_hash": response["tx_hash"], "license_terms_id": target_logs} - except Exception as e: raise e + @deprecated( + "Use register_pil_terms(PILFlavor.non_commercial_social_remixing()) instead.", + ) def register_non_com_social_remixing_pil( self, tx_options: dict | None = None ) -> dict: @@ -158,30 +149,15 @@ def register_non_com_social_remixing_pil( :return dict: A dictionary with the transaction hash and the license terms ID. """ try: - license_terms = self.license_terms_util.get_license_term_by_type( - self.license_terms_util.PIL_TYPE["NON_COMMERCIAL_REMIX"] - ) - - license_terms_id = self._get_license_terms_id(license_terms) - if (license_terms_id is not None) and (license_terms_id != 0): - return {"license_terms_id": license_terms_id} - - response = build_and_send_transaction( - self.web3, - self.account, - self.license_template_client.build_registerLicenseTerms_transaction, - license_terms, - tx_options=tx_options, - ) - - target_logs = self._parse_tx_license_terms_registered_event( - response["tx_receipt"] - ) - return {"tx_hash": response["tx_hash"], "license_terms_id": target_logs} - + license_terms = PILFlavor.non_commercial_social_remixing() + response = self._register_license_terms_helper(license_terms, tx_options) + return response except Exception as e: raise e + @deprecated( + "Use register_pil_terms(PILFlavor.commercial_use(default_minting_fee, currency, royalty_policy)) instead.", + ) def register_commercial_use_pil( self, default_minting_fee: int, @@ -199,38 +175,20 @@ def register_commercial_use_pil( :return dict: A dictionary with the transaction hash and the license terms ID. """ try: - complete_license_terms = self.license_terms_util.get_license_term_by_type( - self.license_terms_util.PIL_TYPE["COMMERCIAL_USE"], - { - "defaultMintingFee": default_minting_fee, - "currency": currency, - "royaltyPolicyAddress": royalty_policy, - }, + license_terms = PILFlavor.commercial_use( + default_minting_fee=default_minting_fee, + currency=currency, + royalty_policy=royalty_policy, ) - - license_terms_id = self._get_license_terms_id(complete_license_terms) - if (license_terms_id is not None) and (license_terms_id != 0): - return {"license_terms_id": license_terms_id} - - response = build_and_send_transaction( - self.web3, - self.account, - self.license_template_client.build_registerLicenseTerms_transaction, - complete_license_terms, - tx_options=tx_options, - ) - tx_hash = response["tx_hash"] - if not response["tx_receipt"]["logs"]: - return {"tx_hash": tx_hash} - - target_logs = self._parse_tx_license_terms_registered_event( - response["tx_receipt"] - ) - return {"tx_hash": tx_hash, "license_terms_id": target_logs} + response = self._register_license_terms_helper(license_terms, tx_options) + return response except Exception as e: raise e + @deprecated( + "Use register_pil_terms(PILFlavor.commercial_remix(default_minting_fee, currency, commercial_rev_share, royalty_policy)) instead.", + ) def register_commercial_remix_pil( self, default_minting_fee: int, @@ -250,39 +208,63 @@ def register_commercial_remix_pil( :return dict: A dictionary with the transaction hash and the license terms ID. """ try: - complete_license_terms = self.license_terms_util.get_license_term_by_type( - self.license_terms_util.PIL_TYPE["COMMERCIAL_REMIX"], - { - "defaultMintingFee": default_minting_fee, - "currency": currency, - "commercialRevShare": commercial_rev_share, - "royaltyPolicyAddress": royalty_policy, - }, + license_terms = PILFlavor.commercial_remix( + default_minting_fee=default_minting_fee, + currency=currency, + commercial_rev_share=commercial_rev_share, + royalty_policy=royalty_policy, ) + response = self._register_license_terms_helper(license_terms, tx_options) + return response - license_terms_id = self._get_license_terms_id(complete_license_terms) - if license_terms_id and license_terms_id != 0: - return {"license_terms_id": license_terms_id} - - response = build_and_send_transaction( - self.web3, - self.account, - self.license_template_client.build_registerLicenseTerms_transaction, - complete_license_terms, - tx_options=tx_options, - ) + except Exception as e: + raise e - tx_hash = response["tx_hash"] - if not response["tx_receipt"]["logs"]: - return {"tx_hash": tx_hash} + def _register_license_terms_helper( + self, license_terms: LicenseTerms, tx_options: dict | None = None + ): + """ + Validate the license terms. - target_logs = self._parse_tx_license_terms_registered_event( - response["tx_receipt"] + :param license_terms LicenseTerms: The license terms. + :param tx_options dict: [Optional] The transaction options. + :return dict: A dictionary with the transaction hash and the license terms ID. + """ + validated_license_terms = PILFlavor.validate_license_terms( + cast(dict, license_terms) + ) + validated_license_terms["commercialRevShare"] = ( + validated_license_terms["commercialRevShare"] * 10**6 + ) + if validated_license_terms["royaltyPolicy"] != ZERO_ADDRESS: + is_whitelisted = self.royalty_module_client.isWhitelistedRoyaltyPolicy( + validated_license_terms["royaltyPolicy"] ) - return {"tx_hash": tx_hash, "license_terms_id": target_logs} + if not is_whitelisted: + raise ValueError("The royalty_policy is not whitelisted.") - except Exception as e: - raise e + if validated_license_terms["currency"] != ZERO_ADDRESS: + is_whitelisted = self.royalty_module_client.isWhitelistedRoyaltyToken( + validated_license_terms["currency"] + ) + if not is_whitelisted: + raise ValueError("The currency is not whitelisted.") + + license_terms_id = self._get_license_terms_id(validated_license_terms) + if (license_terms_id is not None) and (license_terms_id != 0): + return {"license_terms_id": license_terms_id} + + response = build_and_send_transaction( + self.web3, + self.account, + self.license_template_client.build_registerLicenseTerms_transaction, + validated_license_terms, + tx_options=tx_options, + ) + target_logs = self._parse_tx_license_terms_registered_event( + response["tx_receipt"] + ) + return {"tx_hash": response["tx_hash"], "license_terms_id": target_logs} def _parse_tx_license_terms_registered_event(self, tx_receipt: dict) -> int | None: """ diff --git a/src/story_protocol_python_sdk/utils/pil_flavor.py b/src/story_protocol_python_sdk/utils/pil_flavor.py index ebc0289..262594a 100644 --- a/src/story_protocol_python_sdk/utils/pil_flavor.py +++ b/src/story_protocol_python_sdk/utils/pil_flavor.py @@ -5,6 +5,7 @@ from story_protocol_python_sdk.types.resource.Royalty import RoyaltyPolicyInput from story_protocol_python_sdk.utils.constants import ZERO_ADDRESS from story_protocol_python_sdk.utils.royalty import royalty_policy_input_to_address +from story_protocol_python_sdk.utils.validation import validate_address class LicenseTerms(TypedDict): @@ -390,7 +391,7 @@ def validate_license_terms(params: dict) -> LicenseTerms: "derivativesApproval": params.get("derivativesApproval", False), "derivativesReciprocal": params.get("derivativesReciprocal", False), "derivativeRevCeiling": int(params.get("derivativeRevCeiling", 0)), - "currency": params.get("currency", ZERO_ADDRESS), + "currency": validate_address(params.get("currency", ZERO_ADDRESS)), "uri": params.get("uri", ""), } From c58fe6c98b8acc07e03c64faa01308ba600ccd85 Mon Sep 17 00:00:00 2001 From: Bonnie Date: Tue, 9 Dec 2025 17:34:28 +0800 Subject: [PATCH 05/21] feat: enhance License class tests by integrating PILFlavorError for improved validation and error handling --- src/story_protocol_python_sdk/__init__.py | 3 +- tests/unit/resources/test_license.py | 68 ++++++++++++++--------- 2 files changed, 43 insertions(+), 28 deletions(-) diff --git a/src/story_protocol_python_sdk/__init__.py b/src/story_protocol_python_sdk/__init__.py index a08ecd6..6776999 100644 --- a/src/story_protocol_python_sdk/__init__.py +++ b/src/story_protocol_python_sdk/__init__.py @@ -44,7 +44,7 @@ from .utils.derivative_data import DerivativeDataInput from .utils.ip_metadata import IPMetadataInput from .utils.licensing_config_data import LicensingConfig -from .utils.pil_flavor import PILFlavor +from .utils.pil_flavor import PILFlavor, PILFlavorError __all__ = [ "StoryClient", @@ -90,4 +90,5 @@ "WIP_TOKEN_ADDRESS", # utils "PILFlavor", + "PILFlavorError", ] diff --git a/tests/unit/resources/test_license.py b/tests/unit/resources/test_license.py index 23ce92e..78606a3 100644 --- a/tests/unit/resources/test_license.py +++ b/tests/unit/resources/test_license.py @@ -5,9 +5,13 @@ from _pytest.fixtures import fixture from web3 import Web3 -from story_protocol_python_sdk.resources.License import License -from story_protocol_python_sdk.utils.constants import ZERO_ADDRESS -from story_protocol_python_sdk.utils.licensing_config_data import LicensingConfig +from story_protocol_python_sdk import ( + WIP_TOKEN_ADDRESS, + ZERO_ADDRESS, + License, + LicensingConfig, + PILFlavorError, +) from tests.unit.fixtures.data import ADDRESS, CHAIN_ID, IP_ID, TX_HASH from tests.unit.resources.test_ip_account import ZERO_HASH @@ -24,11 +28,11 @@ def test_register_pil_terms_license_terms_id_registered(self, license: License): with patch.object( license.license_template_client, "getLicenseTermsId", return_value=1 ), patch.object( - license.license_terms_util.royalty_module_client, + license.royalty_module_client, "isWhitelistedRoyaltyPolicy", return_value=True, ), patch.object( - license.license_terms_util.royalty_module_client, + license.royalty_module_client, "isWhitelistedRoyaltyToken", return_value=True, ): @@ -59,11 +63,11 @@ def test_register_pil_terms_success(self, license: License): with patch.object( license.license_template_client, "getLicenseTermsId", return_value=0 ), patch.object( - license.license_terms_util.royalty_module_client, + license.royalty_module_client, "isWhitelistedRoyaltyPolicy", return_value=True, ), patch.object( - license.license_terms_util.royalty_module_client, + license.royalty_module_client, "isWhitelistedRoyaltyToken", return_value=True, ), patch.object( @@ -75,7 +79,7 @@ def test_register_pil_terms_success(self, license: License): "gas": 2000000, "gasPrice": Web3.to_wei("100", "gwei"), }, - ): + ) as mock_build_registerLicenseTerms_transaction: response = license.register_pil_terms( transferable=False, @@ -96,7 +100,12 @@ def test_register_pil_terms_success(self, license: License): currency=ADDRESS, uri="", ) - + assert ( + mock_build_registerLicenseTerms_transaction.call_args[0][0][ + "commercialRevShare" + ] + == 90 * 10**6 + ) assert "tx_hash" in response assert response["tx_hash"] == TX_HASH.hex() assert isinstance(response["tx_hash"], str) @@ -107,17 +116,18 @@ def test_register_pil_terms_commercial_rev_share_error_more_than_100( with patch.object( license.license_template_client, "getLicenseTermsId", return_value=0 ), patch.object( - license.license_terms_util.royalty_module_client, + license.royalty_module_client, "isWhitelistedRoyaltyPolicy", return_value=True, ), patch.object( - license.license_terms_util.royalty_module_client, + license.royalty_module_client, "isWhitelistedRoyaltyToken", return_value=True, ): with pytest.raises( - ValueError, match="commercial_rev_share should be between 0 and 100." + PILFlavorError, + match="commercial_rev_share must be between 0 and 100.", ): license.register_pil_terms( transferable=False, @@ -145,17 +155,17 @@ def test_register_pil_terms_commercial_rev_share_error_less_than_0( with patch.object( license.license_template_client, "getLicenseTermsId", return_value=0 ), patch.object( - license.license_terms_util.royalty_module_client, + license.royalty_module_client, "isWhitelistedRoyaltyPolicy", return_value=True, ), patch.object( - license.license_terms_util.royalty_module_client, + license.royalty_module_client, "isWhitelistedRoyaltyToken", return_value=True, ): with pytest.raises( - ValueError, match="commercial_rev_share should be between 0 and 100." + PILFlavorError, match="commercial_rev_share must be between 0 and 100." ): license.register_pil_terms( transferable=False, @@ -218,7 +228,7 @@ def test_register_non_com_social_remixing_pil_error(self, license: License): with patch.object( license.license_template_client, "getLicenseTermsId", return_value=0 ), patch.object( - license.license_terms_util.royalty_module_client, + license.royalty_module_client, "isWhitelistedRoyaltyPolicy", return_value=True, ), patch.object( @@ -241,7 +251,9 @@ def test_register_commercial_use_pil_license_terms_id_registered( license.license_template_client, "getLicenseTermsId", return_value=1 ): response = license.register_commercial_use_pil( - default_minting_fee=1, currency=ZERO_ADDRESS + default_minting_fee=1, + currency=WIP_TOKEN_ADDRESS, + royalty_policy=ADDRESS, ) assert response["license_terms_id"] == 1 assert "tx_hash" not in response @@ -250,7 +262,7 @@ def test_register_commercial_use_pil_success_without_logs(self, license: License with patch.object( license.license_template_client, "getLicenseTermsId", return_value=0 ), patch.object( - license.license_terms_util.royalty_module_client, + license.royalty_module_client, "isWhitelistedRoyaltyPolicy", return_value=True, ), patch.object( @@ -267,7 +279,7 @@ def test_register_commercial_use_pil_success_without_logs(self, license: License ): response = license.register_commercial_use_pil( - default_minting_fee=1, currency=ZERO_ADDRESS + default_minting_fee=1, currency=WIP_TOKEN_ADDRESS ) assert response is not None assert "tx_hash" in response @@ -278,7 +290,7 @@ def test_register_commercial_use_pil_error(self, license: License): with patch.object( license.license_template_client, "getLicenseTermsId", return_value=0 ), patch.object( - license.license_terms_util.royalty_module_client, + license.royalty_module_client, "isWhitelistedRoyaltyPolicy", return_value=True, ), patch.object( @@ -288,7 +300,9 @@ def test_register_commercial_use_pil_error(self, license: License): ): with pytest.raises(Exception, match="request fail."): license.register_commercial_use_pil( - default_minting_fee=1, currency=ZERO_ADDRESS + default_minting_fee=1, + currency=WIP_TOKEN_ADDRESS, + royalty_policy=ADDRESS, ) @@ -301,15 +315,15 @@ def test_register_commercial_remix_pil_license_terms_id_registered( with patch.object( license.license_template_client, "getLicenseTermsId", return_value=1 ), patch.object( - license.license_terms_util.royalty_module_client, + license.royalty_module_client, "isWhitelistedRoyaltyPolicy", return_value=True, ): response = license.register_commercial_remix_pil( default_minting_fee=1, commercial_rev_share=100, - currency=ZERO_ADDRESS, - royalty_policy=ZERO_ADDRESS, + currency=WIP_TOKEN_ADDRESS, + royalty_policy=ADDRESS, ) assert response["license_terms_id"] == 1 assert "tx_hash" not in response @@ -318,7 +332,7 @@ def test_register_commercial_remix_pil_success(self, license: License): with patch.object( license.license_template_client, "getLicenseTermsId", return_value=0 ), patch.object( - license.license_terms_util.royalty_module_client, + license.royalty_module_client, "isWhitelistedRoyaltyPolicy", return_value=True, ), patch.object( @@ -337,8 +351,8 @@ def test_register_commercial_remix_pil_success(self, license: License): response = license.register_commercial_remix_pil( default_minting_fee=1, commercial_rev_share=100, - currency=ZERO_ADDRESS, - royalty_policy=ZERO_ADDRESS, + currency=WIP_TOKEN_ADDRESS, + royalty_policy=ADDRESS, ) assert response is not None assert "tx_hash" in response From e601dd920e2df91c20ed089cd5712169be97da6b Mon Sep 17 00:00:00 2001 From: Bonnie Date: Wed, 10 Dec 2025 10:23:09 +0800 Subject: [PATCH 06/21] feat: enhance PILFlavor class with new method for converting camelCase to snake_case license terms and update return types for license terms methods --- .../utils/pil_flavor.py | 41 +++++++++++++++---- 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/src/story_protocol_python_sdk/utils/pil_flavor.py b/src/story_protocol_python_sdk/utils/pil_flavor.py index 262594a..f0ca8bf 100644 --- a/src/story_protocol_python_sdk/utils/pil_flavor.py +++ b/src/story_protocol_python_sdk/utils/pil_flavor.py @@ -1,7 +1,9 @@ from typing import Optional, TypedDict from ens.ens import Address +from typing_extensions import cast +from story_protocol_python_sdk.types.resource.License import LicenseTermsInput from story_protocol_python_sdk.types.resource.Royalty import RoyaltyPolicyInput from story_protocol_python_sdk.utils.constants import ZERO_ADDRESS from story_protocol_python_sdk.utils.royalty import royalty_policy_input_to_address @@ -261,10 +263,21 @@ def _convert_camel_case_to_snake_case(camel_case_key: str) -> str: return key raise ValueError(f"Unknown camelCase key: {camel_case_key}") # pragma: no cover + @staticmethod + def _convert_camel_case_to_snake_case_license_terms( + terms: LicenseTerms, + ) -> LicenseTermsInput: + """Convert license terms to LicenseTermsInput.""" + result = {} + for key, value in terms.items(): + if key in PILFlavor._OVERRIDE_KEY_MAP: + result[PILFlavor._convert_camel_case_to_snake_case(key)] = value + return cast(LicenseTermsInput, result) + @staticmethod def non_commercial_social_remixing( override: Optional[LicenseTermsOverride] = None, - ) -> LicenseTerms: + ) -> LicenseTermsInput: """ Gets the values to create a Non-Commercial Social Remixing license terms flavor. @@ -276,7 +289,10 @@ def non_commercial_social_remixing( terms = {**PILFlavor._non_commercial_social_remixing_pil} if override: terms.update(PILFlavor._convert_override_to_camel_case(override)) - return PILFlavor.validate_license_terms(terms) + validated_terms = PILFlavor.validate_license_terms(terms) + return PILFlavor._convert_camel_case_to_snake_case_license_terms( + validated_terms + ) @staticmethod def commercial_use( @@ -284,7 +300,7 @@ def commercial_use( currency: Address, royalty_policy: Optional[RoyaltyPolicyInput] = None, override: Optional[LicenseTermsOverride] = None, - ) -> LicenseTerms: + ) -> LicenseTermsInput: """ Gets the values to create a Commercial Use license terms flavor. @@ -304,7 +320,10 @@ def commercial_use( } if override: terms.update(PILFlavor._convert_override_to_camel_case(override)) - return PILFlavor.validate_license_terms(terms) + validated_terms = PILFlavor.validate_license_terms(terms) + return PILFlavor._convert_camel_case_to_snake_case_license_terms( + validated_terms + ) @staticmethod def commercial_remix( @@ -313,7 +332,7 @@ def commercial_remix( commercial_rev_share: int, royalty_policy: Optional[RoyaltyPolicyInput] = None, override: Optional[LicenseTermsOverride] = None, - ) -> LicenseTerms: + ) -> LicenseTermsInput: """ Gets the values to create a Commercial Remixing license terms flavor. @@ -335,14 +354,17 @@ def commercial_remix( } if override: terms.update(PILFlavor._convert_override_to_camel_case(override)) - return PILFlavor.validate_license_terms(terms) + validated_terms = PILFlavor.validate_license_terms(terms) + return PILFlavor._convert_camel_case_to_snake_case_license_terms( + validated_terms + ) @staticmethod def creative_commons_attribution( currency: Address, royalty_policy: Optional[RoyaltyPolicyInput] = None, override: Optional[LicenseTermsOverride] = None, - ) -> LicenseTerms: + ) -> LicenseTermsInput: """ Gets the values to create a Creative Commons Attribution (CC-BY) license terms flavor. @@ -360,7 +382,10 @@ def creative_commons_attribution( } if override: terms.update(PILFlavor._convert_override_to_camel_case(override)) - return PILFlavor.validate_license_terms(terms) + validated_terms = PILFlavor.validate_license_terms(terms) + return PILFlavor._convert_camel_case_to_snake_case_license_terms( + validated_terms + ) @staticmethod def validate_license_terms(params: dict) -> LicenseTerms: From d3f7a146327102726d30f6d80eed75ce8a0739e1 Mon Sep 17 00:00:00 2001 From: Bonnie Date: Wed, 10 Dec 2025 15:18:16 +0800 Subject: [PATCH 07/21] refactor: the pil_flavor class --- src/story_protocol_python_sdk/__init__.py | 3 +- .../utils/pil_flavor.py | 454 +++++------------ tests/unit/utils/test_pil_flavor.py | 482 +++++++++--------- 3 files changed, 378 insertions(+), 561 deletions(-) diff --git a/src/story_protocol_python_sdk/__init__.py b/src/story_protocol_python_sdk/__init__.py index 6776999..5092a83 100644 --- a/src/story_protocol_python_sdk/__init__.py +++ b/src/story_protocol_python_sdk/__init__.py @@ -29,7 +29,7 @@ RegistrationWithRoyaltyVaultAndLicenseTermsResponse, RegistrationWithRoyaltyVaultResponse, ) -from .types.resource.License import LicenseTermsInput +from .types.resource.License import LicenseTermsInput, LicenseTermsOverride from .types.resource.Royalty import NativeRoyaltyPolicy, RoyaltyShareInput from .utils.constants import ( DEFAULT_FUNCTION_SELECTOR, @@ -75,6 +75,7 @@ "RoyaltyShareInput", "NativeRoyaltyPolicy", "LicenseTermsInput", + "LicenseTermsOverride", "MintNFT", "MintedNFT", "RegisterIpAssetResponse", diff --git a/src/story_protocol_python_sdk/utils/pil_flavor.py b/src/story_protocol_python_sdk/utils/pil_flavor.py index f0ca8bf..483d0ca 100644 --- a/src/story_protocol_python_sdk/utils/pil_flavor.py +++ b/src/story_protocol_python_sdk/utils/pil_flavor.py @@ -1,126 +1,27 @@ -from typing import Optional, TypedDict +from dataclasses import asdict, replace +from typing import Optional from ens.ens import Address -from typing_extensions import cast -from story_protocol_python_sdk.types.resource.License import LicenseTermsInput +from story_protocol_python_sdk.types.resource.License import ( + LicenseTermsInput, + LicenseTermsOverride, +) from story_protocol_python_sdk.types.resource.Royalty import RoyaltyPolicyInput from story_protocol_python_sdk.utils.constants import ZERO_ADDRESS from story_protocol_python_sdk.utils.royalty import royalty_policy_input_to_address from story_protocol_python_sdk.utils.validation import validate_address -class LicenseTerms(TypedDict): - """ - The normalized license terms structure used internally by the SDK. - Uses camelCase keys to match the contract interface. - """ - - transferable: bool - royaltyPolicy: Address - defaultMintingFee: int - expiration: int - commercialUse: bool - commercialAttribution: bool - commercializerChecker: Address - commercializerCheckerData: str - commercialRevShare: int - commercialRevCeiling: int - derivativesAllowed: bool - derivativesAttribution: bool - derivativesApproval: bool - derivativesReciprocal: bool - derivativeRevCeiling: int - currency: Address - uri: str - - -class LicenseTermsOverride(TypedDict, total=False): - """ - Optional override parameters for license terms. - Uses snake_case keys following SDK conventions. - """ - - transferable: bool - """Whether the license is transferable.""" - royalty_policy: RoyaltyPolicyInput - """The type of royalty policy to be used.""" - default_minting_fee: int - """The fee to be paid when minting a license.""" - expiration: int - """The expiration period of the license.""" - commercial_use: bool - """Whether commercial use is allowed.""" - commercial_attribution: bool - """Whether commercial attribution is required.""" - commercializer_checker: Address - """The address of the commercializer checker contract.""" - commercializer_checker_data: str - """Percentage of revenue that must be shared with the licensor. Must be between 0 and 100.""" - commercial_rev_share: int - """Percentage of revenue that must be shared with the licensor.""" - commercial_rev_ceiling: int - """The maximum revenue that can be collected from commercial use.""" - derivatives_allowed: bool - """Whether derivatives are allowed.""" - derivatives_attribution: bool - """Whether attribution is required for derivatives.""" - derivatives_approval: bool - """Whether approval is required for derivatives.""" - derivatives_reciprocal: bool - """Whether derivatives must have the same license terms.""" - derivative_rev_ceiling: int - """The maximum revenue that can be collected from derivatives.""" - currency: Address - """The ERC20 token to be used to pay the minting fee.""" - uri: str - """The URI of the license terms.""" - - -class NonCommercialSocialRemixingRequest(TypedDict, total=False): - """Request parameters for non-commercial social remixing license.""" - - override: LicenseTermsOverride - """Optional overrides for the default license terms.""" - - -class CommercialUseRequest(TypedDict, total=False): - """Request parameters for commercial use license.""" - - default_minting_fee: int - """The fee to be paid when minting a license.""" - currency: Address - """The ERC20 token to be used to pay the minting fee.""" - royalty_policy: RoyaltyPolicyInput - """The type of royalty policy to be used. Default is LAP.""" - override: LicenseTermsOverride - """Optional overrides for the default license terms.""" - - -class CommercialRemixRequest(TypedDict, total=False): - """Request parameters for commercial remix license.""" - - default_minting_fee: int - """The fee to be paid when minting a license.""" - commercial_rev_share: int - """Percentage of revenue that must be shared with the licensor. Must be between 0 and 100.""" - currency: Address - """The ERC20 token to be used to pay the minting fee.""" - royalty_policy: RoyaltyPolicyInput - """The type of royalty policy to be used. Default is LAP.""" - override: LicenseTermsOverride - """Optional overrides for the default license terms.""" - - -class CreativeCommonsAttributionRequest(TypedDict, total=False): - """Request parameters for creative commons attribution license.""" - - currency: Address - """The ERC20 token to be used to pay the minting fee.""" - royalty_policy: RoyaltyPolicyInput - """The type of royalty policy to be used. Default is LAP.""" - override: LicenseTermsOverride - """Optional overrides for the default license terms.""" +def _apply_override( + base: LicenseTermsInput, override: Optional[LicenseTermsOverride] +) -> LicenseTermsInput: + """Apply override values to base license terms, ignoring None values.""" + if not override: + return base + # Filter out None values from override + overrides = {k: v for k, v in asdict(override).items() if v is not None} + return replace(base, **overrides) # PIL URIs for off-chain terms @@ -132,25 +33,25 @@ class CreativeCommonsAttributionRequest(TypedDict, total=False): } # Common default values for license terms -COMMON_DEFAULTS: LicenseTerms = { - "transferable": True, - "royaltyPolicy": ZERO_ADDRESS, - "defaultMintingFee": 0, - "expiration": 0, - "commercialUse": False, - "commercialAttribution": False, - "commercializerChecker": ZERO_ADDRESS, - "commercializerCheckerData": ZERO_ADDRESS, - "commercialRevShare": 0, - "commercialRevCeiling": 0, - "derivativesAllowed": False, - "derivativesAttribution": False, - "derivativesApproval": False, - "derivativesReciprocal": False, - "derivativeRevCeiling": 0, - "currency": ZERO_ADDRESS, - "uri": "", -} +COMMON_DEFAULTS: LicenseTermsInput = LicenseTermsInput( + transferable=True, + royalty_policy=ZERO_ADDRESS, + default_minting_fee=0, + expiration=0, + commercial_use=False, + commercial_attribution=False, + commercializer_checker=ZERO_ADDRESS, + commercializer_checker_data=ZERO_ADDRESS, + commercial_rev_share=0, + commercial_rev_ceiling=0, + derivatives_allowed=False, + derivatives_attribution=False, + derivatives_approval=False, + derivatives_reciprocal=False, + derivative_rev_ceiling=0, + currency=ZERO_ADDRESS, + uri="", +) class PILFlavorError(Exception): @@ -180,99 +81,49 @@ class PILFlavor: remix_license = PILFlavor.non_commercial_social_remixing() """ - _non_commercial_social_remixing_pil: LicenseTerms = { - **COMMON_DEFAULTS, - "commercialUse": False, - "commercialAttribution": False, - "derivativesAllowed": True, - "derivativesAttribution": True, - "derivativesApproval": False, - "derivativesReciprocal": True, - "uri": PIL_URIS["NCSR"], - } - - _commercial_use: LicenseTerms = { - **COMMON_DEFAULTS, - "commercialUse": True, - "commercialAttribution": True, - "derivativesAllowed": False, - "derivativesAttribution": False, - "derivativesApproval": False, - "derivativesReciprocal": False, - "uri": PIL_URIS["COMMERCIAL_USE"], - } - - _commercial_remix: LicenseTerms = { - **COMMON_DEFAULTS, - "commercialUse": True, - "commercialAttribution": True, - "derivativesAllowed": True, - "derivativesAttribution": True, - "derivativesApproval": False, - "derivativesReciprocal": True, - "uri": PIL_URIS["COMMERCIAL_REMIX"], - } - - _creative_commons_attribution: LicenseTerms = { - **COMMON_DEFAULTS, - "commercialUse": True, - "commercialAttribution": True, - "derivativesAllowed": True, - "derivativesAttribution": True, - "derivativesApproval": False, - "derivativesReciprocal": True, - "uri": PIL_URIS["CC_BY"], - } - - # Mapping from snake_case to camelCase for license terms - _OVERRIDE_KEY_MAP = { - "transferable": "transferable", - "royalty_policy": "royaltyPolicy", - "default_minting_fee": "defaultMintingFee", - "expiration": "expiration", - "commercial_use": "commercialUse", - "commercial_attribution": "commercialAttribution", - "commercializer_checker": "commercializerChecker", - "commercializer_checker_data": "commercializerCheckerData", - "commercial_rev_share": "commercialRevShare", - "commercial_rev_ceiling": "commercialRevCeiling", - "derivatives_allowed": "derivativesAllowed", - "derivatives_attribution": "derivativesAttribution", - "derivatives_approval": "derivativesApproval", - "derivatives_reciprocal": "derivativesReciprocal", - "derivative_rev_ceiling": "derivativeRevCeiling", - "currency": "currency", - "uri": "uri", - } - - @staticmethod - def _convert_override_to_camel_case(override: LicenseTermsOverride) -> dict: - """Convert snake_case override keys to camelCase for internal use.""" - result = {} - for key, value in override.items(): - camel_key = PILFlavor._OVERRIDE_KEY_MAP.get(key) - if camel_key: - result[camel_key] = value - return result - - @staticmethod - def _convert_camel_case_to_snake_case(camel_case_key: str) -> str: - """Convert camelCase to snake_case for internal use.""" - for key, value in PILFlavor._OVERRIDE_KEY_MAP.items(): - if value == camel_case_key: - return key - raise ValueError(f"Unknown camelCase key: {camel_case_key}") # pragma: no cover - - @staticmethod - def _convert_camel_case_to_snake_case_license_terms( - terms: LicenseTerms, - ) -> LicenseTermsInput: - """Convert license terms to LicenseTermsInput.""" - result = {} - for key, value in terms.items(): - if key in PILFlavor._OVERRIDE_KEY_MAP: - result[PILFlavor._convert_camel_case_to_snake_case(key)] = value - return cast(LicenseTermsInput, result) + _non_commercial_social_remixing_pil = replace( + COMMON_DEFAULTS, + commercial_use=False, + commercial_attribution=False, + derivatives_allowed=True, + derivatives_attribution=True, + derivatives_approval=False, + derivatives_reciprocal=True, + uri=PIL_URIS["NCSR"], + ) + + _commercial_use = replace( + COMMON_DEFAULTS, + commercial_use=True, + commercial_attribution=True, + derivatives_allowed=False, + derivatives_attribution=False, + derivatives_approval=False, + derivatives_reciprocal=False, + uri=PIL_URIS["COMMERCIAL_USE"], + ) + + _commercial_remix = replace( + COMMON_DEFAULTS, + commercial_use=True, + commercial_attribution=True, + derivatives_allowed=True, + derivatives_attribution=True, + derivatives_approval=False, + derivatives_reciprocal=True, + uri=PIL_URIS["COMMERCIAL_REMIX"], + ) + + _creative_commons_attribution = replace( + COMMON_DEFAULTS, + commercial_use=True, + commercial_attribution=True, + derivatives_allowed=True, + derivatives_attribution=True, + derivatives_approval=False, + derivatives_reciprocal=True, + uri=PIL_URIS["CC_BY"], + ) @staticmethod def non_commercial_social_remixing( @@ -286,13 +137,8 @@ def non_commercial_social_remixing( :param override: Optional overrides for the default license terms. :return: The license terms dictionary. """ - terms = {**PILFlavor._non_commercial_social_remixing_pil} - if override: - terms.update(PILFlavor._convert_override_to_camel_case(override)) - validated_terms = PILFlavor.validate_license_terms(terms) - return PILFlavor._convert_camel_case_to_snake_case_license_terms( - validated_terms - ) + terms = _apply_override(PILFlavor._non_commercial_social_remixing_pil, override) + return PILFlavor.validate_license_terms(terms) @staticmethod def commercial_use( @@ -312,18 +158,14 @@ def commercial_use( :param override: Optional overrides for the default license terms. :return: The license terms dictionary. """ - terms = { - **PILFlavor._commercial_use, - "defaultMintingFee": default_minting_fee, - "currency": currency, - "royaltyPolicy": royalty_policy, - } - if override: - terms.update(PILFlavor._convert_override_to_camel_case(override)) - validated_terms = PILFlavor.validate_license_terms(terms) - return PILFlavor._convert_camel_case_to_snake_case_license_terms( - validated_terms + base = replace( + PILFlavor._commercial_use, + default_minting_fee=default_minting_fee, + currency=currency, + royalty_policy=royalty_policy, ) + terms = _apply_override(base, override) + return PILFlavor.validate_license_terms(terms) @staticmethod def commercial_remix( @@ -345,19 +187,15 @@ def commercial_remix( :param override: Optional overrides for the default license terms. :return: The license terms dictionary. """ - terms = { - **PILFlavor._commercial_remix, - "defaultMintingFee": default_minting_fee, - "currency": currency, - "commercialRevShare": commercial_rev_share, - "royaltyPolicy": royalty_policy, - } - if override: - terms.update(PILFlavor._convert_override_to_camel_case(override)) - validated_terms = PILFlavor.validate_license_terms(terms) - return PILFlavor._convert_camel_case_to_snake_case_license_terms( - validated_terms + base = replace( + PILFlavor._commercial_remix, + default_minting_fee=default_minting_fee, + currency=currency, + commercial_rev_share=commercial_rev_share, + royalty_policy=royalty_policy, ) + terms = _apply_override(base, override) + return PILFlavor.validate_license_terms(terms) @staticmethod def creative_commons_attribution( @@ -375,20 +213,16 @@ def creative_commons_attribution( :param override: Optional overrides for the default license terms. :return: The license terms dictionary. """ - terms = { - **PILFlavor._creative_commons_attribution, - "currency": currency, - "royaltyPolicy": royalty_policy, - } - if override: - terms.update(PILFlavor._convert_override_to_camel_case(override)) - validated_terms = PILFlavor.validate_license_terms(terms) - return PILFlavor._convert_camel_case_to_snake_case_license_terms( - validated_terms + base = replace( + PILFlavor._creative_commons_attribution, + currency=currency, + royalty_policy=royalty_policy, ) + terms = _apply_override(base, override) + return PILFlavor.validate_license_terms(terms) @staticmethod - def validate_license_terms(params: dict) -> LicenseTerms: + def validate_license_terms(params: LicenseTermsInput) -> LicenseTermsInput: """ Validates and normalizes license terms. @@ -396,32 +230,14 @@ def validate_license_terms(params: dict) -> LicenseTerms: :return: The validated and normalized license terms. :raises PILFlavorError: If validation fails. """ - normalized: LicenseTerms = { - "transferable": params.get("transferable", True), - "royaltyPolicy": royalty_policy_input_to_address( - params.get("royaltyPolicy") - ), - "defaultMintingFee": int(params.get("defaultMintingFee", 0)), - "expiration": int(params.get("expiration", 0)), - "commercialUse": params.get("commercialUse", False), - "commercialAttribution": params.get("commercialAttribution", False), - "commercializerChecker": params.get("commercializerChecker", ZERO_ADDRESS), - "commercializerCheckerData": params.get( - "commercializerCheckerData", ZERO_ADDRESS - ), - "commercialRevShare": params.get("commercialRevShare", 0), - "commercialRevCeiling": int(params.get("commercialRevCeiling", 0)), - "derivativesAllowed": params.get("derivativesAllowed", False), - "derivativesAttribution": params.get("derivativesAttribution", False), - "derivativesApproval": params.get("derivativesApproval", False), - "derivativesReciprocal": params.get("derivativesReciprocal", False), - "derivativeRevCeiling": int(params.get("derivativeRevCeiling", 0)), - "currency": validate_address(params.get("currency", ZERO_ADDRESS)), - "uri": params.get("uri", ""), - } - - royalty_policy = normalized["royaltyPolicy"] - currency = normalized["currency"] + # Normalize royalty_policy to address + royalty_policy = royalty_policy_input_to_address(params.royalty_policy) + currency = validate_address(params.currency) + + normalized = replace( + params, + royalty_policy=royalty_policy, + ) # Validate royalty policy and currency relationship if royalty_policy != ZERO_ADDRESS and currency == ZERO_ADDRESS: @@ -429,16 +245,13 @@ def validate_license_terms(params: dict) -> LicenseTerms: "royalty_policy is not zero address and currency cannot be zero address." ) - # Validate defaultMintingFee - if normalized["defaultMintingFee"] < 0: + # Validate default_minting_fee + if normalized.default_minting_fee < 0: raise PILFlavorError( "default_minting_fee should be greater than or equal to 0." ) - if ( - normalized["defaultMintingFee"] > 0 - and normalized["royaltyPolicy"] == ZERO_ADDRESS - ): + if normalized.default_minting_fee > 0 and royalty_policy == ZERO_ADDRESS: raise PILFlavorError( "royalty_policy is required when default_minting_fee is greater than 0." ) @@ -447,54 +260,53 @@ def validate_license_terms(params: dict) -> LicenseTerms: PILFlavor._verify_commercial_use(normalized) PILFlavor._verify_derivatives(normalized) - if ( - normalized["commercialRevShare"] > 100 - or normalized["commercialRevShare"] < 0 - ): + if normalized.commercial_rev_share > 100 or normalized.commercial_rev_share < 0: raise PILFlavorError("commercial_rev_share must be between 0 and 100.") return normalized @staticmethod - def _verify_commercial_use(terms: LicenseTerms) -> None: + def _verify_commercial_use(terms: LicenseTermsInput) -> None: """Verify commercial use related fields.""" - if not terms["commercialUse"]: + royalty_policy = royalty_policy_input_to_address(terms.royalty_policy) + + if not terms.commercial_use: commercial_fields = [ - ("commercialAttribution", terms["commercialAttribution"]), + ("commercial_attribution", terms.commercial_attribution), ( - "commercializerChecker", - terms["commercializerChecker"] != ZERO_ADDRESS, + "commercializer_checker", + terms.commercializer_checker != ZERO_ADDRESS, ), - ("commercialRevShare", terms["commercialRevShare"] > 0), - ("commercialRevCeiling", terms["commercialRevCeiling"] > 0), - ("derivativeRevCeiling", terms["derivativeRevCeiling"] > 0), - ("royaltyPolicy", terms["royaltyPolicy"] != ZERO_ADDRESS), + ("commercial_rev_share", terms.commercial_rev_share > 0), + ("commercial_rev_ceiling", terms.commercial_rev_ceiling > 0), + ("derivative_rev_ceiling", terms.derivative_rev_ceiling > 0), + ("royalty_policy", royalty_policy != ZERO_ADDRESS), ] for field, value in commercial_fields: if value: raise PILFlavorError( - f"cannot add {PILFlavor._convert_camel_case_to_snake_case(field)} when commercial_use is False." + f"cannot add {field} when commercial_use is False." ) else: - if terms["royaltyPolicy"] == ZERO_ADDRESS: + if royalty_policy == ZERO_ADDRESS: raise PILFlavorError( "royalty_policy is required when commercial_use is True." ) @staticmethod - def _verify_derivatives(terms: LicenseTerms) -> None: + def _verify_derivatives(terms: LicenseTermsInput) -> None: """Verify derivatives related fields.""" - if not terms["derivativesAllowed"]: + if not terms.derivatives_allowed: derivative_fields = [ - ("derivativesAttribution", terms["derivativesAttribution"]), - ("derivativesApproval", terms["derivativesApproval"]), - ("derivativesReciprocal", terms["derivativesReciprocal"]), - ("derivativeRevCeiling", terms["derivativeRevCeiling"] > 0), + ("derivatives_attribution", terms.derivatives_attribution), + ("derivatives_approval", terms.derivatives_approval), + ("derivatives_reciprocal", terms.derivatives_reciprocal), + ("derivative_rev_ceiling", terms.derivative_rev_ceiling > 0), ] for field, value in derivative_fields: if value: raise PILFlavorError( - f"cannot add {PILFlavor._convert_camel_case_to_snake_case(field)} when derivatives_allowed is False." + f"cannot add {field} when derivatives_allowed is False." ) diff --git a/tests/unit/utils/test_pil_flavor.py b/tests/unit/utils/test_pil_flavor.py index 3ba4363..35d92b5 100644 --- a/tests/unit/utils/test_pil_flavor.py +++ b/tests/unit/utils/test_pil_flavor.py @@ -5,6 +5,8 @@ ROYALTY_POLICY_LRP_ADDRESS, WIP_TOKEN_ADDRESS, ZERO_ADDRESS, + LicenseTermsInput, + LicenseTermsOverride, NativeRoyaltyPolicy, PILFlavor, ) @@ -21,55 +23,55 @@ class TestNonCommercialSocialRemixing: def test_default_values(self): """Test default values.""" pil_flavor = PILFlavor.non_commercial_social_remixing() - assert pil_flavor == { - "transferable": True, - "commercialAttribution": False, - "commercialRevCeiling": 0, - "commercialRevShare": 0, - "commercialUse": False, - "commercializerChecker": ZERO_ADDRESS, - "commercializerCheckerData": ZERO_ADDRESS, - "currency": ZERO_ADDRESS, - "derivativeRevCeiling": 0, - "derivativesAllowed": True, - "derivativesApproval": False, - "derivativesAttribution": True, - "derivativesReciprocal": True, - "expiration": 0, - "defaultMintingFee": 0, - "royaltyPolicy": ZERO_ADDRESS, - "uri": "https://github.com/piplabs/pil-document/blob/998c13e6ee1d04eb817aefd1fe16dfe8be3cd7a2/off-chain-terms/NCSR.json", - } + assert pil_flavor == LicenseTermsInput( + transferable=True, + commercial_attribution=False, + commercial_rev_ceiling=0, + commercial_rev_share=0, + commercial_use=False, + commercializer_checker=ZERO_ADDRESS, + commercializer_checker_data=ZERO_ADDRESS, + currency=ZERO_ADDRESS, + derivative_rev_ceiling=0, + derivatives_allowed=True, + derivatives_approval=False, + derivatives_attribution=True, + derivatives_reciprocal=True, + expiration=0, + default_minting_fee=0, + royalty_policy=ZERO_ADDRESS, + uri="https://github.com/piplabs/pil-document/blob/998c13e6ee1d04eb817aefd1fe16dfe8be3cd7a2/off-chain-terms/NCSR.json", + ) def test_override_values(self): """Test override values.""" pil_flavor = PILFlavor.non_commercial_social_remixing( - override={ - "commercial_use": True, - "commercial_attribution": True, - "royalty_policy": NativeRoyaltyPolicy.LAP, - "currency": WIP_TOKEN_ADDRESS, - } + override=LicenseTermsOverride( + commercial_use=True, + commercial_attribution=True, + royalty_policy=NativeRoyaltyPolicy.LAP, + currency=WIP_TOKEN_ADDRESS, + ), + ) + assert pil_flavor == LicenseTermsInput( + transferable=True, + commercial_attribution=True, + commercial_rev_ceiling=0, + commercial_rev_share=0, + commercial_use=True, + commercializer_checker=ZERO_ADDRESS, + commercializer_checker_data=ZERO_ADDRESS, + currency=WIP_TOKEN_ADDRESS, + derivative_rev_ceiling=0, + derivatives_allowed=True, + derivatives_approval=False, + derivatives_attribution=True, + derivatives_reciprocal=True, + expiration=0, + default_minting_fee=0, + royalty_policy=ROYALTY_POLICY_LAP_ADDRESS, + uri="https://github.com/piplabs/pil-document/blob/998c13e6ee1d04eb817aefd1fe16dfe8be3cd7a2/off-chain-terms/NCSR.json", ) - assert pil_flavor == { - "transferable": True, - "commercialAttribution": True, - "commercialRevCeiling": 0, - "commercialRevShare": 0, - "commercialUse": True, - "commercializerChecker": ZERO_ADDRESS, - "commercializerCheckerData": ZERO_ADDRESS, - "currency": WIP_TOKEN_ADDRESS, - "derivativeRevCeiling": 0, - "derivativesAllowed": True, - "derivativesApproval": False, - "derivativesAttribution": True, - "derivativesReciprocal": True, - "expiration": 0, - "defaultMintingFee": 0, - "royaltyPolicy": ROYALTY_POLICY_LAP_ADDRESS, - "uri": "https://github.com/piplabs/pil-document/blob/998c13e6ee1d04eb817aefd1fe16dfe8be3cd7a2/off-chain-terms/NCSR.json", - } def test_throw_commercial_attribution_error_when_commercial_use_is_false(self): """Test throw commercial attribution error when commercial use is false.""" @@ -78,7 +80,7 @@ def test_throw_commercial_attribution_error_when_commercial_use_is_false(self): match="cannot add commercial_attribution when commercial_use is False.", ): PILFlavor.non_commercial_social_remixing( - override={"commercial_attribution": True}, + override=LicenseTermsOverride(commercial_attribution=True), ) def test_throw_commercializer_checker_error_when_commercial_use_is_false(self): @@ -88,7 +90,7 @@ def test_throw_commercializer_checker_error_when_commercial_use_is_false(self): match="cannot add commercializer_checker when commercial_use is False.", ): PILFlavor.non_commercial_social_remixing( - override={"commercializer_checker": ADDRESS}, + override=LicenseTermsOverride(commercializer_checker=ADDRESS), ) def test_throw_commercial_rev_share_error_when_commercial_use_is_false(self): @@ -98,7 +100,7 @@ def test_throw_commercial_rev_share_error_when_commercial_use_is_false(self): match="cannot add commercial_rev_share when commercial_use is False.", ): PILFlavor.non_commercial_social_remixing( - override={"commercial_rev_share": 10}, + override=LicenseTermsOverride(commercial_rev_share=10), ) def test_throw_commercial_rev_ceiling_error_when_commercial_use_is_false(self): @@ -108,7 +110,7 @@ def test_throw_commercial_rev_ceiling_error_when_commercial_use_is_false(self): match="cannot add commercial_rev_ceiling when commercial_use is False.", ): PILFlavor.non_commercial_social_remixing( - override={"commercial_rev_ceiling": 10000}, + override=LicenseTermsOverride(commercial_rev_ceiling=10000), ) def test_throw_derivative_rev_ceiling_error_when_commercial_use_is_false(self): @@ -118,7 +120,7 @@ def test_throw_derivative_rev_ceiling_error_when_commercial_use_is_false(self): match="cannot add derivative_rev_ceiling when commercial_use is False.", ): PILFlavor.non_commercial_social_remixing( - override={"derivative_rev_ceiling": 10000}, + override=LicenseTermsOverride(derivative_rev_ceiling=10000), ) def test_throw_royalty_policy_error_when_commercial_use_is_false(self): @@ -128,7 +130,9 @@ def test_throw_royalty_policy_error_when_commercial_use_is_false(self): match="cannot add royalty_policy when commercial_use is False.", ): PILFlavor.non_commercial_social_remixing( - override={"royalty_policy": ADDRESS, "currency": WIP_TOKEN_ADDRESS}, + override=LicenseTermsOverride( + royalty_policy=ADDRESS, currency=WIP_TOKEN_ADDRESS + ), ) class TestCommercialUse: @@ -141,25 +145,25 @@ def test_default_values(self): currency=WIP_TOKEN_ADDRESS, royalty_policy=ADDRESS, ) - assert pil_flavor == { - "transferable": True, - "commercialAttribution": True, - "commercialRevCeiling": 0, - "commercialRevShare": 0, - "commercialUse": True, - "commercializerChecker": ZERO_ADDRESS, - "commercializerCheckerData": ZERO_ADDRESS, - "currency": WIP_TOKEN_ADDRESS, - "derivativeRevCeiling": 0, - "derivativesAllowed": False, - "derivativesApproval": False, - "derivativesAttribution": False, - "derivativesReciprocal": False, - "expiration": 0, - "defaultMintingFee": 10000, - "royaltyPolicy": ADDRESS, - "uri": "https://github.com/piplabs/pil-document/blob/9a1f803fcf8101a8a78f1dcc929e6014e144ab56/off-chain-terms/CommercialUse.json", - } + assert pil_flavor == LicenseTermsInput( + transferable=True, + commercial_attribution=True, + commercial_rev_ceiling=0, + commercial_rev_share=0, + commercial_use=True, + commercializer_checker=ZERO_ADDRESS, + commercializer_checker_data=ZERO_ADDRESS, + currency=WIP_TOKEN_ADDRESS, + derivative_rev_ceiling=0, + derivatives_allowed=False, + derivatives_approval=False, + derivatives_attribution=False, + derivatives_reciprocal=False, + expiration=0, + default_minting_fee=10000, + royalty_policy=ADDRESS, + uri="https://github.com/piplabs/pil-document/blob/9a1f803fcf8101a8a78f1dcc929e6014e144ab56/off-chain-terms/CommercialUse.json", + ) def test_without_royalty_policy(self): """Test without royalty policy.""" @@ -167,25 +171,25 @@ def test_without_royalty_policy(self): default_minting_fee=10000, currency=WIP_TOKEN_ADDRESS, ) - assert pil_flavor == { - "transferable": True, - "commercialAttribution": True, - "commercialRevCeiling": 0, - "commercialRevShare": 0, - "commercialUse": True, - "commercializerChecker": ZERO_ADDRESS, - "commercializerCheckerData": ZERO_ADDRESS, - "currency": WIP_TOKEN_ADDRESS, - "derivativeRevCeiling": 0, - "derivativesAllowed": False, - "derivativesApproval": False, - "derivativesAttribution": False, - "derivativesReciprocal": False, - "expiration": 0, - "defaultMintingFee": 10000, - "royaltyPolicy": ROYALTY_POLICY_LAP_ADDRESS, - "uri": "https://github.com/piplabs/pil-document/blob/9a1f803fcf8101a8a78f1dcc929e6014e144ab56/off-chain-terms/CommercialUse.json", - } + assert pil_flavor == LicenseTermsInput( + transferable=True, + commercial_attribution=True, + commercial_rev_ceiling=0, + commercial_rev_share=0, + commercial_use=True, + commercializer_checker=ZERO_ADDRESS, + commercializer_checker_data=ZERO_ADDRESS, + currency=WIP_TOKEN_ADDRESS, + derivative_rev_ceiling=0, + derivatives_allowed=False, + derivatives_approval=False, + derivatives_attribution=False, + derivatives_reciprocal=False, + expiration=0, + default_minting_fee=10000, + royalty_policy=ROYALTY_POLICY_LAP_ADDRESS, + uri="https://github.com/piplabs/pil-document/blob/9a1f803fcf8101a8a78f1dcc929e6014e144ab56/off-chain-terms/CommercialUse.json", + ) def test_with_custom_values(self): """Test with custom values.""" @@ -193,37 +197,37 @@ def test_with_custom_values(self): default_minting_fee=10000, currency=WIP_TOKEN_ADDRESS, royalty_policy=ADDRESS, - override={ - "commercial_attribution": False, - "derivatives_allowed": True, - "derivatives_attribution": True, - "derivatives_approval": False, - "derivatives_reciprocal": True, - "uri": "https://example.com", - "royalty_policy": NativeRoyaltyPolicy.LRP, - "default_minting_fee": 10, - "commercial_rev_share": 10, - }, + override=LicenseTermsOverride( + commercial_attribution=False, + derivatives_allowed=True, + derivatives_attribution=True, + derivatives_approval=False, + derivatives_reciprocal=True, + uri="https://example.com", + royalty_policy=NativeRoyaltyPolicy.LRP, + default_minting_fee=10, + commercial_rev_share=10, + ), + ) + assert pil_flavor == LicenseTermsInput( + transferable=True, + commercial_attribution=False, + commercial_rev_ceiling=0, + commercial_rev_share=10, + commercial_use=True, + commercializer_checker=ZERO_ADDRESS, + commercializer_checker_data=ZERO_ADDRESS, + currency=WIP_TOKEN_ADDRESS, + derivative_rev_ceiling=0, + derivatives_allowed=True, + derivatives_approval=False, + derivatives_attribution=True, + derivatives_reciprocal=True, + expiration=0, + default_minting_fee=10, + royalty_policy=ROYALTY_POLICY_LRP_ADDRESS, + uri="https://example.com", ) - assert pil_flavor == { - "transferable": True, - "commercialAttribution": False, - "commercialRevCeiling": 0, - "commercialRevShare": 10, - "commercialUse": True, - "commercializerChecker": ZERO_ADDRESS, - "commercializerCheckerData": ZERO_ADDRESS, - "currency": WIP_TOKEN_ADDRESS, - "derivativeRevCeiling": 0, - "derivativesAllowed": True, - "derivativesApproval": False, - "derivativesAttribution": True, - "derivativesReciprocal": True, - "expiration": 0, - "defaultMintingFee": 10, - "royaltyPolicy": ROYALTY_POLICY_LRP_ADDRESS, - "uri": "https://example.com", - } def test_throw_error_when_royalty_policy_is_not_zero_address_and_currency_is_zero_address( self, @@ -260,7 +264,7 @@ def test_not_throw_error_when_default_minting_fee_is_zero_and_royalty_policy_is_ currency=WIP_TOKEN_ADDRESS, royalty_policy=ADDRESS, ) - assert pil_flavor.get("defaultMintingFee") == 0 + assert pil_flavor.default_minting_fee == 0 def test_not_throw_error_when_default_minting_fee_is_100_(self): """Test not throw error when default minting fee is 100""" @@ -269,7 +273,7 @@ def test_not_throw_error_when_default_minting_fee_is_100_(self): currency=WIP_TOKEN_ADDRESS, royalty_policy=ADDRESS, ) - assert pil_flavor.get("defaultMintingFee") == 100 + assert pil_flavor.default_minting_fee == 100 def test_throw_error_when_default_minting_fee_is_greater_than_zero_and_royalty_policy_is_zero_address( self, @@ -294,7 +298,7 @@ def test_throw_error_when_commercial_rev_share_is_less_than_zero(self): default_minting_fee=10000, currency=WIP_TOKEN_ADDRESS, royalty_policy=ADDRESS, - override={"commercial_rev_share": -1}, + override=LicenseTermsOverride(commercial_rev_share=-1), ) def test_throw_error_when_commercial_rev_share_is_greater_than_100(self): @@ -306,7 +310,7 @@ def test_throw_error_when_commercial_rev_share_is_greater_than_100(self): default_minting_fee=10000, currency=WIP_TOKEN_ADDRESS, royalty_policy=ADDRESS, - override={"commercial_rev_share": 101}, + override=LicenseTermsOverride(commercial_rev_share=101), ) def test_throw_error_when_commercial_is_true_and_royalty_policy_is_zero_address( @@ -333,25 +337,25 @@ def test_default_values(self): currency=WIP_TOKEN_ADDRESS, commercial_rev_share=10, ) - assert pil_flavor == { - "commercialAttribution": True, - "commercialRevCeiling": 0, - "commercialRevShare": 10, - "commercialUse": True, - "commercializerChecker": ZERO_ADDRESS, - "commercializerCheckerData": ZERO_ADDRESS, - "currency": WIP_TOKEN_ADDRESS, - "transferable": True, - "derivativeRevCeiling": 0, - "derivativesAllowed": True, - "derivativesApproval": False, - "derivativesAttribution": True, - "derivativesReciprocal": True, - "expiration": 0, - "defaultMintingFee": 10000, - "royaltyPolicy": ROYALTY_POLICY_LAP_ADDRESS, - "uri": "https://github.com/piplabs/pil-document/blob/ad67bb632a310d2557f8abcccd428e4c9c798db1/off-chain-terms/CommercialRemix.json", - } + assert pil_flavor == LicenseTermsInput( + transferable=True, + commercial_attribution=True, + commercial_rev_ceiling=0, + commercial_rev_share=10, + commercial_use=True, + commercializer_checker=ZERO_ADDRESS, + commercializer_checker_data=ZERO_ADDRESS, + currency=WIP_TOKEN_ADDRESS, + derivative_rev_ceiling=0, + derivatives_allowed=True, + derivatives_approval=False, + derivatives_attribution=True, + derivatives_reciprocal=True, + expiration=0, + default_minting_fee=10000, + royalty_policy=ROYALTY_POLICY_LAP_ADDRESS, + uri="https://github.com/piplabs/pil-document/blob/ad67bb632a310d2557f8abcccd428e4c9c798db1/off-chain-terms/CommercialRemix.json", + ) def test_with_custom_values(self): """Test with custom values.""" @@ -359,37 +363,37 @@ def test_with_custom_values(self): default_minting_fee=10000, currency=WIP_TOKEN_ADDRESS, commercial_rev_share=100, - override={ - "commercial_attribution": False, - "derivatives_allowed": True, - "derivatives_attribution": True, - "derivatives_approval": False, - "derivatives_reciprocal": True, - "uri": "https://example.com", - "royalty_policy": NativeRoyaltyPolicy.LRP, - "default_minting_fee": 10, - "commercial_rev_share": 10, - }, + override=LicenseTermsOverride( + commercial_attribution=False, + derivatives_allowed=True, + derivatives_attribution=True, + derivatives_approval=False, + derivatives_reciprocal=True, + uri="https://example.com", + royalty_policy=NativeRoyaltyPolicy.LRP, + default_minting_fee=10, + commercial_rev_share=10, + ), + ) + assert pil_flavor == LicenseTermsInput( + transferable=True, + commercial_attribution=False, + commercial_rev_ceiling=0, + derivative_rev_ceiling=0, + derivatives_allowed=True, + derivatives_approval=False, + derivatives_attribution=True, + derivatives_reciprocal=True, + currency=WIP_TOKEN_ADDRESS, + commercial_rev_share=10, + commercial_use=True, + commercializer_checker=ZERO_ADDRESS, + commercializer_checker_data=ZERO_ADDRESS, + expiration=0, + default_minting_fee=10, + royalty_policy=ROYALTY_POLICY_LRP_ADDRESS, + uri="https://example.com", ) - assert pil_flavor == { - "transferable": True, - "commercialAttribution": False, - "commercialRevCeiling": 0, - "derivativeRevCeiling": 0, - "derivativesAllowed": True, - "derivativesApproval": False, - "derivativesAttribution": True, - "derivativesReciprocal": True, - "currency": WIP_TOKEN_ADDRESS, - "commercialRevShare": 10, - "commercialUse": True, - "commercializerChecker": ZERO_ADDRESS, - "commercializerCheckerData": ZERO_ADDRESS, - "expiration": 0, - "defaultMintingFee": 10, - "royaltyPolicy": ROYALTY_POLICY_LRP_ADDRESS, - "uri": "https://example.com", - } class TestCreativeCommonsAttribution: """Test creative commons attribution PIL flavor.""" @@ -399,59 +403,59 @@ def test_default_values(self): pil_flavor = PILFlavor.creative_commons_attribution( currency=WIP_TOKEN_ADDRESS, ) - assert pil_flavor == { - "transferable": True, - "commercialAttribution": True, - "commercialRevCeiling": 0, - "commercialRevShare": 0, - "commercialUse": True, - "commercializerChecker": ZERO_ADDRESS, - "commercializerCheckerData": ZERO_ADDRESS, - "currency": WIP_TOKEN_ADDRESS, - "derivativeRevCeiling": 0, - "derivativesAllowed": True, - "derivativesApproval": False, - "derivativesAttribution": True, - "derivativesReciprocal": True, - "expiration": 0, - "defaultMintingFee": 0, - "royaltyPolicy": ROYALTY_POLICY_LAP_ADDRESS, - "uri": "https://github.com/piplabs/pil-document/blob/998c13e6ee1d04eb817aefd1fe16dfe8be3cd7a2/off-chain-terms/CC-BY.json", - } + assert pil_flavor == LicenseTermsInput( + transferable=True, + commercial_attribution=True, + commercial_rev_ceiling=0, + commercial_rev_share=0, + commercial_use=True, + commercializer_checker=ZERO_ADDRESS, + commercializer_checker_data=ZERO_ADDRESS, + currency=WIP_TOKEN_ADDRESS, + derivative_rev_ceiling=0, + derivatives_allowed=True, + derivatives_approval=False, + derivatives_attribution=True, + derivatives_reciprocal=True, + expiration=0, + default_minting_fee=0, + royalty_policy=ROYALTY_POLICY_LAP_ADDRESS, + uri="https://github.com/piplabs/pil-document/blob/998c13e6ee1d04eb817aefd1fe16dfe8be3cd7a2/off-chain-terms/CC-BY.json", + ) def test_with_custom_values(self): """Test with custom values.""" pil_flavor = PILFlavor.creative_commons_attribution( currency=WIP_TOKEN_ADDRESS, - override={ - "commercial_attribution": False, - "derivatives_allowed": True, - "derivatives_attribution": True, - "derivatives_approval": False, - "derivatives_reciprocal": True, - "uri": "https://example.com", - "royalty_policy": ADDRESS, - }, + override=LicenseTermsOverride( + commercial_attribution=False, + derivatives_allowed=True, + derivatives_attribution=True, + derivatives_approval=False, + derivatives_reciprocal=True, + uri="https://example.com", + royalty_policy=ADDRESS, + ), + ) + assert pil_flavor == LicenseTermsInput( + transferable=True, + commercial_attribution=False, + commercial_rev_ceiling=0, + commercial_rev_share=0, + commercial_use=True, + commercializer_checker=ZERO_ADDRESS, + commercializer_checker_data=ZERO_ADDRESS, + currency=WIP_TOKEN_ADDRESS, + derivative_rev_ceiling=0, + derivatives_allowed=True, + derivatives_approval=False, + derivatives_attribution=True, + derivatives_reciprocal=True, + expiration=0, + default_minting_fee=0, + royalty_policy=ADDRESS, + uri="https://example.com", ) - assert pil_flavor == { - "transferable": True, - "commercialAttribution": False, - "commercialRevCeiling": 0, - "commercialRevShare": 0, - "commercialUse": True, - "commercializerChecker": ZERO_ADDRESS, - "commercializerCheckerData": ZERO_ADDRESS, - "currency": WIP_TOKEN_ADDRESS, - "derivativeRevCeiling": 0, - "derivativesAllowed": True, - "derivativesApproval": False, - "derivativesAttribution": True, - "derivativesReciprocal": True, - "expiration": 0, - "defaultMintingFee": 0, - "royaltyPolicy": ADDRESS, - "uri": "https://example.com", - } def test_throw_derivatives_attribution_error_when_derivatives_allowed_is_false( self, @@ -463,9 +467,9 @@ def test_throw_derivatives_attribution_error_when_derivatives_allowed_is_false( ): PILFlavor.creative_commons_attribution( currency=WIP_TOKEN_ADDRESS, - override={ - "derivatives_allowed": False, - }, + override=LicenseTermsOverride( + derivatives_allowed=False, + ), ) def test_throw_derivatives_approval_error_when_derivatives_allowed_is_false( @@ -478,11 +482,11 @@ def test_throw_derivatives_approval_error_when_derivatives_allowed_is_false( ): PILFlavor.creative_commons_attribution( currency=WIP_TOKEN_ADDRESS, - override={ - "derivatives_allowed": False, - "derivatives_approval": True, - "derivatives_attribution": False, - }, + override=LicenseTermsOverride( + derivatives_allowed=False, + derivatives_approval=True, + derivatives_attribution=False, + ), ) def test_throw_derivatives_reciprocal_error_when_derivatives_allowed_is_false( @@ -495,12 +499,12 @@ def test_throw_derivatives_reciprocal_error_when_derivatives_allowed_is_false( ): PILFlavor.creative_commons_attribution( currency=WIP_TOKEN_ADDRESS, - override={ - "derivatives_allowed": False, - "derivatives_reciprocal": True, - "derivatives_attribution": False, - "derivatives_approval": False, - }, + override=LicenseTermsOverride( + derivatives_allowed=False, + derivatives_reciprocal=True, + derivatives_attribution=False, + derivatives_approval=False, + ), ) def test_throw_derivative_rev_ceiling_error_when_derivatives_allowed_is_false( @@ -513,11 +517,11 @@ def test_throw_derivative_rev_ceiling_error_when_derivatives_allowed_is_false( ): PILFlavor.creative_commons_attribution( currency=WIP_TOKEN_ADDRESS, - override={ - "derivatives_allowed": False, - "derivative_rev_ceiling": 10000, - "derivatives_attribution": False, - "derivatives_approval": False, - "derivatives_reciprocal": False, - }, + override=LicenseTermsOverride( + derivatives_allowed=False, + derivative_rev_ceiling=10000, + derivatives_attribution=False, + derivatives_approval=False, + derivatives_reciprocal=False, + ), ) From f0882e1e64a9a592145cc0c5860edbfcfb93a3a9 Mon Sep 17 00:00:00 2001 From: Bonnie Date: Wed, 10 Dec 2025 16:29:49 +0800 Subject: [PATCH 08/21] feat: update License class to use LicenseTermsInput for license terms management and enhance deprecation messages for clarity --- .../resources/License.py | 63 ++++++++++--------- .../types/resource/License.py | 46 ++++++++++++++ tests/unit/resources/test_license.py | 26 +++----- 3 files changed, 88 insertions(+), 47 deletions(-) diff --git a/src/story_protocol_python_sdk/resources/License.py b/src/story_protocol_python_sdk/resources/License.py index acb373f..2dffa8e 100644 --- a/src/story_protocol_python_sdk/resources/License.py +++ b/src/story_protocol_python_sdk/resources/License.py @@ -1,5 +1,3 @@ -from typing import cast - from ens.ens import Address, HexStr from typing_extensions import deprecated from web3 import Web3 @@ -23,6 +21,7 @@ RoyaltyModuleClient, ) from story_protocol_python_sdk.types.common import RevShareType +from story_protocol_python_sdk.types.resource.License import LicenseTermsInput from story_protocol_python_sdk.utils.constants import ZERO_ADDRESS from story_protocol_python_sdk.utils.licensing_config_data import ( LicensingConfig, @@ -112,22 +111,22 @@ def register_pil_terms( """ try: return self._register_license_terms_helper( - license_terms=LicenseTerms( + license_terms=LicenseTermsInput( transferable=transferable, - royaltyPolicy=royalty_policy, - defaultMintingFee=default_minting_fee, + royalty_policy=royalty_policy, + default_minting_fee=default_minting_fee, expiration=expiration, - commercialUse=commercial_use, - commercialAttribution=commercial_attribution, - commercializerChecker=commercializer_checker, - commercializerCheckerData=commercializer_checker_data, - commercialRevShare=commercial_rev_share, - commercialRevCeiling=commercial_rev_ceiling, - derivativesAllowed=derivatives_allowed, - derivativesAttribution=derivatives_attribution, - derivativesApproval=derivatives_approval, - derivativesReciprocal=derivatives_reciprocal, - derivativeRevCeiling=derivative_rev_ceiling, + commercial_use=commercial_use, + commercial_attribution=commercial_attribution, + commercializer_checker=commercializer_checker, + commercializer_checker_data=commercializer_checker_data, + commercial_rev_share=commercial_rev_share, + commercial_rev_ceiling=commercial_rev_ceiling, + derivatives_allowed=derivatives_allowed, + derivatives_attribution=derivatives_attribution, + derivatives_approval=derivatives_approval, + derivatives_reciprocal=derivatives_reciprocal, + derivative_rev_ceiling=derivative_rev_ceiling, currency=currency, uri=uri, ), @@ -137,7 +136,9 @@ def register_pil_terms( raise e @deprecated( - "Use register_pil_terms(PILFlavor.non_commercial_social_remixing()) instead.", + "Use register_pil_terms(**asdict(PILFlavor.non_commercial_social_remixing())) instead. " + "In the next major version, register_pil_terms will accept LicenseTermsInput directly, " + "so you can use register_pil_terms(PILFlavor.non_commercial_social_remixing()) without asdict.", ) def register_non_com_social_remixing_pil( self, tx_options: dict | None = None @@ -156,7 +157,9 @@ def register_non_com_social_remixing_pil( raise e @deprecated( - "Use register_pil_terms(PILFlavor.commercial_use(default_minting_fee, currency, royalty_policy)) instead.", + "Use register_pil_terms(**asdict(PILFlavor.commercial_use(default_minting_fee, currency, royalty_policy))) instead. " + "In the next major version, register_pil_terms will accept LicenseTermsInput directly, " + "so you can use register_pil_terms(PILFlavor.commercial_use(...)) without asdict.", ) def register_commercial_use_pil( self, @@ -187,7 +190,9 @@ def register_commercial_use_pil( raise e @deprecated( - "Use register_pil_terms(PILFlavor.commercial_remix(default_minting_fee, currency, commercial_rev_share, royalty_policy)) instead.", + "Use register_pil_terms(**asdict(PILFlavor.commercial_remix(default_minting_fee, currency, commercial_rev_share, royalty_policy))) instead. " + "In the next major version, register_pil_terms will accept LicenseTermsInput directly, " + "so you can use register_pil_terms(PILFlavor.commercial_remix(...)) without asdict.", ) def register_commercial_remix_pil( self, @@ -221,31 +226,29 @@ def register_commercial_remix_pil( raise e def _register_license_terms_helper( - self, license_terms: LicenseTerms, tx_options: dict | None = None + self, license_terms: LicenseTermsInput, tx_options: dict | None = None ): """ Validate the license terms. - :param license_terms LicenseTerms: The license terms. + :param license_terms LicenseTermsInput: The license terms. :param tx_options dict: [Optional] The transaction options. :return dict: A dictionary with the transaction hash and the license terms ID. """ - validated_license_terms = PILFlavor.validate_license_terms( - cast(dict, license_terms) - ) - validated_license_terms["commercialRevShare"] = ( - validated_license_terms["commercialRevShare"] * 10**6 + validated_license_terms = PILFlavor.validate_license_terms(license_terms) + validated_license_terms.commercial_rev_share = get_revenue_share( + validated_license_terms.commercial_rev_share * 10**6 ) - if validated_license_terms["royaltyPolicy"] != ZERO_ADDRESS: + if validated_license_terms.royalty_policy != ZERO_ADDRESS: is_whitelisted = self.royalty_module_client.isWhitelistedRoyaltyPolicy( - validated_license_terms["royaltyPolicy"] + validated_license_terms.royalty_policy ) if not is_whitelisted: raise ValueError("The royalty_policy is not whitelisted.") - if validated_license_terms["currency"] != ZERO_ADDRESS: + if validated_license_terms.currency != ZERO_ADDRESS: is_whitelisted = self.royalty_module_client.isWhitelistedRoyaltyToken( - validated_license_terms["currency"] + validated_license_terms.currency ) if not is_whitelisted: raise ValueError("The currency is not whitelisted.") diff --git a/src/story_protocol_python_sdk/types/resource/License.py b/src/story_protocol_python_sdk/types/resource/License.py index 8de050f..eb2d88a 100644 --- a/src/story_protocol_python_sdk/types/resource/License.py +++ b/src/story_protocol_python_sdk/types/resource/License.py @@ -1,10 +1,56 @@ from dataclasses import dataclass +from typing import Optional from ens.ens import Address, HexStr from story_protocol_python_sdk.types.resource.Royalty import RoyaltyPolicyInput +@dataclass +class LicenseTermsOverride: + """ + Optional override parameters for license terms. + All fields are optional and default to None. + + Attributes: + transferable: Whether the license is transferable. + royalty_policy: The type of royalty policy to be used. + default_minting_fee: The fee to be paid when minting a license. + expiration: The expiration period of the license. + commercial_use: Whether commercial use is allowed. + commercial_attribution: Whether commercial attribution is required. + commercializer_checker: The address of the commercializer checker contract. + 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. + commercial_rev_ceiling: The maximum revenue that can be collected from commercial use. + derivatives_allowed: Whether derivatives are allowed. + derivatives_attribution: Whether attribution is required for derivatives. + derivatives_approval: Whether approval is required for derivatives. + derivatives_reciprocal: Whether derivatives must have the same license terms. + derivative_rev_ceiling: The maximum revenue that can be collected from derivatives. + currency: The ERC20 token to be used to pay the minting fee. + uri: The URI of the license terms. + """ + + transferable: Optional[bool] = None + royalty_policy: Optional[RoyaltyPolicyInput] = None + default_minting_fee: Optional[int] = None + expiration: Optional[int] = None + commercial_use: Optional[bool] = None + commercial_attribution: Optional[bool] = None + commercializer_checker: Optional[Address] = None + commercializer_checker_data: Optional[Address | HexStr] = None + commercial_rev_share: Optional[int] = None + commercial_rev_ceiling: Optional[int] = None + derivatives_allowed: Optional[bool] = None + derivatives_attribution: Optional[bool] = None + derivatives_approval: Optional[bool] = None + derivatives_reciprocal: Optional[bool] = None + derivative_rev_ceiling: Optional[int] = None + currency: Optional[Address] = None + uri: Optional[str] = None + + @dataclass class LicenseTermsInput: """ diff --git a/tests/unit/resources/test_license.py b/tests/unit/resources/test_license.py index 78606a3..e194ab5 100644 --- a/tests/unit/resources/test_license.py +++ b/tests/unit/resources/test_license.py @@ -1,3 +1,4 @@ +from dataclasses import asdict from typing import Callable from unittest.mock import patch @@ -10,6 +11,7 @@ ZERO_ADDRESS, License, LicensingConfig, + PILFlavor, PILFlavorError, ) from tests.unit.fixtures.data import ADDRESS, CHAIN_ID, IP_ID, TX_HASH @@ -38,23 +40,13 @@ def test_register_pil_terms_license_terms_id_registered(self, license: License): ): response = license.register_pil_terms( - default_minting_fee=1513, - currency=ADDRESS, - royalty_policy=ADDRESS, - transferable=False, - expiration=0, - commercial_use=True, - commercial_attribution=False, - commercializer_checker=ZERO_ADDRESS, - commercializer_checker_data="0x", - commercial_rev_share=0, - commercial_rev_ceiling=0, - derivatives_allowed=False, - derivatives_attribution=False, - derivatives_approval=False, - derivatives_reciprocal=False, - derivative_rev_ceiling=0, - uri="", + **asdict( + PILFlavor.commercial_use( + default_minting_fee=1513, + currency=ADDRESS, + royalty_policy=ADDRESS, + ) + ) ) assert response["license_terms_id"] == 1 assert "tx_hash" not in response From f1cae2b5c2458bfd7bf1f02ff57069ebbbda5199 Mon Sep 17 00:00:00 2001 From: Bonnie Date: Wed, 10 Dec 2025 16:54:27 +0800 Subject: [PATCH 09/21] feat: add unit tests for various PILFlavor registration scenarios, including non-commercial and commercial use cases --- .../resources/License.py | 12 +- tests/unit/resources/test_license.py | 152 +++++++++++++++++- 2 files changed, 155 insertions(+), 9 deletions(-) diff --git a/src/story_protocol_python_sdk/resources/License.py b/src/story_protocol_python_sdk/resources/License.py index 2dffa8e..f1cc32c 100644 --- a/src/story_protocol_python_sdk/resources/License.py +++ b/src/story_protocol_python_sdk/resources/License.py @@ -1,3 +1,5 @@ +from dataclasses import asdict + from ens.ens import Address, HexStr from typing_extensions import deprecated from web3 import Web3 @@ -27,7 +29,7 @@ LicensingConfig, LicensingConfigData, ) -from story_protocol_python_sdk.utils.pil_flavor import LicenseTerms, PILFlavor +from story_protocol_python_sdk.utils.pil_flavor import PILFlavor from story_protocol_python_sdk.utils.transaction_utils import build_and_send_transaction from story_protocol_python_sdk.utils.validation import ( get_revenue_share, @@ -56,11 +58,11 @@ def __init__(self, web3: Web3, account, chain_id: int): self.module_registry_client = ModuleRegistryClient(web3) self.royalty_module_client = RoyaltyModuleClient(web3) - def _get_license_terms_id(self, license_terms: LicenseTerms) -> int: + def _get_license_terms_id(self, license_terms: dict) -> int: """ Get the ID of the license terms. - :param license_terms LicenseTerms: The license terms. + :param license_terms dict: The license terms. :return int: The ID of the license terms. """ return self.license_template_client.getLicenseTermsId(license_terms) @@ -237,7 +239,7 @@ def _register_license_terms_helper( """ validated_license_terms = PILFlavor.validate_license_terms(license_terms) validated_license_terms.commercial_rev_share = get_revenue_share( - validated_license_terms.commercial_rev_share * 10**6 + validated_license_terms.commercial_rev_share ) if validated_license_terms.royalty_policy != ZERO_ADDRESS: is_whitelisted = self.royalty_module_client.isWhitelistedRoyaltyPolicy( @@ -253,7 +255,7 @@ def _register_license_terms_helper( if not is_whitelisted: raise ValueError("The currency is not whitelisted.") - license_terms_id = self._get_license_terms_id(validated_license_terms) + license_terms_id = self._get_license_terms_id(asdict(validated_license_terms)) if (license_terms_id is not None) and (license_terms_id != 0): return {"license_terms_id": license_terms_id} diff --git a/tests/unit/resources/test_license.py b/tests/unit/resources/test_license.py index e194ab5..c1a0e74 100644 --- a/tests/unit/resources/test_license.py +++ b/tests/unit/resources/test_license.py @@ -1,4 +1,4 @@ -from dataclasses import asdict +from dataclasses import asdict, replace from typing import Callable from unittest.mock import patch @@ -93,9 +93,9 @@ def test_register_pil_terms_success(self, license: License): uri="", ) assert ( - mock_build_registerLicenseTerms_transaction.call_args[0][0][ - "commercialRevShare" - ] + mock_build_registerLicenseTerms_transaction.call_args[0][ + 0 + ].commercial_rev_share == 90 * 10**6 ) assert "tx_hash" in response @@ -179,6 +179,150 @@ def test_register_pil_terms_commercial_rev_share_error_less_than_0( uri="", ) + def test_register_non_commercial_social_remixing_pil_success( + self, license: License + ): + with patch.object( + license.license_template_client, "getLicenseTermsId", return_value=0 + ), patch.object( + license.royalty_module_client, + "isWhitelistedRoyaltyPolicy", + return_value=True, + ), patch.object( + license.royalty_module_client, + "isWhitelistedRoyaltyToken", + return_value=True, + ), patch.object( + license.license_template_client, + "build_registerLicenseTerms_transaction", + return_value={ + "from": ADDRESS, + "nonce": 1, + "gas": 2000000, + "gasPrice": Web3.to_wei("100", "gwei"), + }, + ) as mock_build_registerLicenseTerms_transaction: + + license.register_pil_terms( + **asdict(PILFlavor.non_commercial_social_remixing()) + ) + assert ( + mock_build_registerLicenseTerms_transaction.call_args[0][0] + == PILFlavor.non_commercial_social_remixing() + ) + + def test_register_commercial_remix_pil_success(self, license: License): + with patch.object( + license.license_template_client, "getLicenseTermsId", return_value=0 + ), patch.object( + license.royalty_module_client, + "isWhitelistedRoyaltyPolicy", + return_value=True, + ), patch.object( + license.royalty_module_client, + "isWhitelistedRoyaltyToken", + return_value=True, + ), patch.object( + license.license_template_client, + "build_registerLicenseTerms_transaction", + return_value={ + "from": ADDRESS, + "nonce": 1, + "gas": 2000000, + "gasPrice": Web3.to_wei("100", "gwei"), + }, + ) as mock_build_registerLicenseTerms_transaction: + + license.register_pil_terms( + **asdict( + PILFlavor.commercial_remix( + default_minting_fee=1513, + currency=ADDRESS, + commercial_rev_share=90, + ) + ) + ) + assert mock_build_registerLicenseTerms_transaction.call_args[0][0] == replace( + PILFlavor.commercial_remix( + default_minting_fee=1513, + currency=ADDRESS, + commercial_rev_share=90, + ), + commercial_rev_share=90 * 10**6, + ) + + def test_register_commercial_use_pil_success(self, license: License): + with patch.object( + license.license_template_client, "getLicenseTermsId", return_value=0 + ), patch.object( + license.royalty_module_client, + "isWhitelistedRoyaltyPolicy", + return_value=True, + ), patch.object( + license.royalty_module_client, + "isWhitelistedRoyaltyToken", + return_value=True, + ), patch.object( + license.license_template_client, + "build_registerLicenseTerms_transaction", + return_value={ + "from": ADDRESS, + "nonce": 1, + "gas": 2000000, + "gasPrice": Web3.to_wei("100", "gwei"), + }, + ) as mock_build_registerLicenseTerms_transaction: + + license.register_pil_terms( + **asdict( + PILFlavor.commercial_use( + default_minting_fee=1513, + currency=ADDRESS, + ) + ) + ) + assert mock_build_registerLicenseTerms_transaction.call_args[0][ + 0 + ] == PILFlavor.commercial_use( + default_minting_fee=1513, + currency=ADDRESS, + ) + + def test_register_creative_commons_attribution_pil_success(self, license: License): + with patch.object( + license.license_template_client, "getLicenseTermsId", return_value=0 + ), patch.object( + license.royalty_module_client, + "isWhitelistedRoyaltyPolicy", + return_value=True, + ), patch.object( + license.royalty_module_client, + "isWhitelistedRoyaltyToken", + return_value=True, + ), patch.object( + license.license_template_client, + "build_registerLicenseTerms_transaction", + return_value={ + "from": ADDRESS, + "nonce": 1, + "gas": 2000000, + "gasPrice": Web3.to_wei("100", "gwei"), + }, + ) as mock_build_registerLicenseTerms_transaction: + + license.register_pil_terms( + **asdict( + PILFlavor.creative_commons_attribution( + currency=ADDRESS, + ) + ) + ) + assert mock_build_registerLicenseTerms_transaction.call_args[0][ + 0 + ] == PILFlavor.creative_commons_attribution( + currency=ADDRESS, + ) + class TestNonComSocialRemixingPIL: """Tests for non-commercial social remixing PIL functionality.""" From 6af77a255d42ae66ef131e5e32fc65be22c642e4 Mon Sep 17 00:00:00 2001 From: Bonnie Date: Thu, 11 Dec 2025 11:32:25 +0800 Subject: [PATCH 10/21] feat: enhance IPAsset and License classes with camelCase conversion for license terms and improve validation logic --- .../resources/IPAsset.py | 18 ++- .../resources/License.py | 9 +- .../utils/constants.py | 2 +- .../utils/royalty.py | 13 ++- src/story_protocol_python_sdk/utils/util.py | 19 ++++ .../integration/test_integration_ip_asset.py | 107 ++++-------------- 6 files changed, 70 insertions(+), 98 deletions(-) create mode 100644 src/story_protocol_python_sdk/utils/util.py diff --git a/src/story_protocol_python_sdk/resources/IPAsset.py b/src/story_protocol_python_sdk/resources/IPAsset.py index 79558ed..6ff3fa3 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, is_dataclass +from dataclasses import asdict, is_dataclass, replace from typing import cast from ens.ens import Address, HexStr @@ -68,6 +68,7 @@ RegistrationWithRoyaltyVaultAndLicenseTermsResponse, RegistrationWithRoyaltyVaultResponse, ) +from story_protocol_python_sdk.types.resource.License import LicenseTermsInput from story_protocol_python_sdk.types.resource.Royalty import RoyaltyShareInput from story_protocol_python_sdk.utils.constants import ( DEADLINE, @@ -87,10 +88,13 @@ is_initial_ip_metadata, ) from story_protocol_python_sdk.utils.license_terms import LicenseTerms +from story_protocol_python_sdk.utils.pil_flavor import PILFlavor 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.util import convert_dict_keys_to_camel_case from story_protocol_python_sdk.utils.validation import ( + get_revenue_share, validate_address, validate_max_rts, ) @@ -1329,7 +1333,6 @@ def register_ip_and_attach_pil_terms_and_distribute_royalty_tokens( 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, @@ -2181,9 +2184,18 @@ def _validate_license_terms_data( terms_dict = term["terms"] licensing_config_dict = term["licensing_config"] + license_terms = PILFlavor.validate_license_terms( + LicenseTermsInput(**terms_dict) + ) + license_terms = replace( + license_terms, + commercial_rev_share=get_revenue_share( + license_terms.commercial_rev_share + ), + ) validated_license_terms_data.append( { - "terms": self.license_terms_util.validate_license_terms(terms_dict), + "terms": convert_dict_keys_to_camel_case(asdict(license_terms)), "licensingConfig": self.license_terms_util.validate_licensing_config( licensing_config_dict ), diff --git a/src/story_protocol_python_sdk/resources/License.py b/src/story_protocol_python_sdk/resources/License.py index f1cc32c..9ac973a 100644 --- a/src/story_protocol_python_sdk/resources/License.py +++ b/src/story_protocol_python_sdk/resources/License.py @@ -31,6 +31,7 @@ ) from story_protocol_python_sdk.utils.pil_flavor import PILFlavor from story_protocol_python_sdk.utils.transaction_utils import build_and_send_transaction +from story_protocol_python_sdk.utils.util import convert_dict_keys_to_camel_case from story_protocol_python_sdk.utils.validation import ( get_revenue_share, validate_address, @@ -254,8 +255,10 @@ def _register_license_terms_helper( ) if not is_whitelisted: raise ValueError("The currency is not whitelisted.") - - license_terms_id = self._get_license_terms_id(asdict(validated_license_terms)) + camel_case_license_terms = convert_dict_keys_to_camel_case( + asdict(validated_license_terms) + ) + license_terms_id = self._get_license_terms_id(camel_case_license_terms) if (license_terms_id is not None) and (license_terms_id != 0): return {"license_terms_id": license_terms_id} @@ -263,7 +266,7 @@ def _register_license_terms_helper( self.web3, self.account, self.license_template_client.build_registerLicenseTerms_transaction, - validated_license_terms, + camel_case_license_terms, tx_options=tx_options, ) target_logs = self._parse_tx_license_terms_registered_event( diff --git a/src/story_protocol_python_sdk/utils/constants.py b/src/story_protocol_python_sdk/utils/constants.py index 1035c44..7151c27 100644 --- a/src/story_protocol_python_sdk/utils/constants.py +++ b/src/story_protocol_python_sdk/utils/constants.py @@ -4,7 +4,7 @@ DEFAULT_FUNCTION_SELECTOR = "0x00000000" MAX_ROYALTY_TOKEN = 100_000_000 ROYALTY_POLICY_LAP_ADDRESS = "0xBe54FB168b3c982b7AaE60dB6CF75Bd8447b390E" -ROYALTY_POLICY_LRP_ADDRESS = "0x9156E603C949481883B1D3355C6F1132D191FC41" +ROYALTY_POLICY_LRP_ADDRESS = "0x9156e603C949481883B1d3355c6f1132D191fC41" WIP_TOKEN_ADDRESS = "0x1514000000000000000000000000000000000000" # Default deadline for signature in seconds DEADLINE = 1000 diff --git a/src/story_protocol_python_sdk/utils/royalty.py b/src/story_protocol_python_sdk/utils/royalty.py index 367fca1..e1f122a 100644 --- a/src/story_protocol_python_sdk/utils/royalty.py +++ b/src/story_protocol_python_sdk/utils/royalty.py @@ -3,6 +3,7 @@ from typing import List from ens.ens import Address +from typing_extensions import cast from story_protocol_python_sdk.types.resource.Royalty import ( NativeRoyaltyPolicy, @@ -79,15 +80,17 @@ def royalty_policy_input_to_address( Raises: ValueError: If the custom address is invalid. """ + print("--------------------------------") + print(input) + print(input == NativeRoyaltyPolicy.LAP) + print(input == NativeRoyaltyPolicy.LRP) + print("--------------------------------") 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 + else: + return validate_address(cast(str, input)) diff --git a/src/story_protocol_python_sdk/utils/util.py b/src/story_protocol_python_sdk/utils/util.py new file mode 100644 index 0000000..85e8b35 --- /dev/null +++ b/src/story_protocol_python_sdk/utils/util.py @@ -0,0 +1,19 @@ +def snake_to_camel(snake_str: str) -> str: + """ + Convert a snake_case string to camelCase. + + :param snake_str str: The snake_case string to convert. + :return str: The camelCase string. + """ + components = snake_str.split("_") + return components[0] + "".join(word.capitalize() for word in components[1:]) + + +def convert_dict_keys_to_camel_case(snake_dict: dict) -> dict: + """ + Convert all keys in a dictionary from snake_case to camelCase. + + :param snake_dict dict: The dictionary with snake_case keys. + :return dict: A new dictionary with camelCase keys. + """ + return {snake_to_camel(key): value for key, value in snake_dict.items()} diff --git a/tests/integration/test_integration_ip_asset.py b/tests/integration/test_integration_ip_asset.py index 6ed8b1e..a8076cd 100644 --- a/tests/integration/test_integration_ip_asset.py +++ b/tests/integration/test_integration_ip_asset.py @@ -1,3 +1,5 @@ +from dataclasses import asdict + import pytest from story_protocol_python_sdk import ( @@ -8,9 +10,12 @@ IPMetadataInput, LicenseTermsDataInput, LicenseTermsInput, + LicenseTermsOverride, LicensingConfig, MintedNFT, MintNFT, + NativeRoyaltyPolicy, + PILFlavor, RoyaltyShareInput, StoryClient, ) @@ -114,8 +119,8 @@ def child_ip_id(self, story_client: StoryClient): @pytest.fixture(scope="module") def non_commercial_license(self, story_client: StoryClient): - license_register_response = ( - story_client.License.register_non_com_social_remixing_pil() + license_register_response = story_client.License.register_pil_terms( + **asdict(PILFlavor.non_commercial_social_remixing()) ) no_commercial_license_terms_id = license_register_response["license_terms_id"] return no_commercial_license_terms_id @@ -1234,24 +1239,9 @@ def test_register_ip_asset_minted_with_license_terms( ), 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, + terms=PILFlavor.commercial_use( + default_minting_fee=10, currency=WIP_TOKEN_ADDRESS, - uri="test-minted-license-terms", ), licensing_config=LicensingConfig( is_set=True, @@ -1300,24 +1290,10 @@ def test_register_ip_asset_minted_with_license_terms_and_royalty_shares( ), 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, + terms=PILFlavor.commercial_remix( + default_minting_fee=10, currency=WIP_TOKEN_ADDRESS, - uri="test-minted-license-terms-with-royalty", + commercial_rev_share=10, ), licensing_config=LicensingConfig( is_set=True, @@ -1382,24 +1358,8 @@ def test_register_ip_asset_mint_with_license_terms( ), 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, + terms=PILFlavor.creative_commons_attribution( currency=WIP_TOKEN_ADDRESS, - uri="test-mint-license-terms", ), licensing_config=LicensingConfig( is_set=True, @@ -1444,24 +1404,13 @@ def test_register_ip_asset_mint_with_license_terms_and_royalty_shares( ), 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-mint-license-terms-with-royalty", + terms=PILFlavor.non_commercial_social_remixing( + override=LicenseTermsOverride( + commercial_use=True, + commercial_attribution=True, + royalty_policy=NativeRoyaltyPolicy.LRP, + currency=WIP_TOKEN_ADDRESS, + ), ), licensing_config=LicensingConfig( is_set=True, @@ -1505,24 +1454,10 @@ def parent_ip_with_commercial_license( ), license_terms_data=[ LicenseTermsDataInput( - terms=LicenseTermsInput( - transferable=True, - royalty_policy=ROYALTY_POLICY, + terms=PILFlavor.commercial_remix( default_minting_fee=0, - expiration=0, - 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-parent-license-for-derivative", ), licensing_config=LicensingConfig( is_set=True, From 87edb74dd0b9958be5fa288c59393aa29197bdcd Mon Sep 17 00:00:00 2001 From: Bonnie Date: Thu, 11 Dec 2025 14:49:22 +0800 Subject: [PATCH 11/21] refactor: replace Web3 checksum address validation with a dedicated validate_address function and update related unit tests --- .../utils/derivative_data.py | 8 ++- tests/unit/conftest.py | 16 ----- tests/unit/utils/test_derivative_data.py | 65 +++++++------------ 3 files changed, 30 insertions(+), 59 deletions(-) diff --git a/src/story_protocol_python_sdk/utils/derivative_data.py b/src/story_protocol_python_sdk/utils/derivative_data.py index 7927e78..7b2d9df 100644 --- a/src/story_protocol_python_sdk/utils/derivative_data.py +++ b/src/story_protocol_python_sdk/utils/derivative_data.py @@ -15,7 +15,10 @@ ) from story_protocol_python_sdk.types.common import RevShareType from story_protocol_python_sdk.utils.constants import MAX_ROYALTY_TOKEN, ZERO_ADDRESS -from story_protocol_python_sdk.utils.validation import get_revenue_share +from story_protocol_python_sdk.utils.validation import ( + get_revenue_share, + validate_address, +) @dataclass @@ -110,12 +113,11 @@ def validate_parent_ip_ids_and_license_terms_ids(self): raise ValueError( "The number of parent IP IDs must match the number of license terms IDs." ) - total_royalty_percent = 0 for parent_ip_id, license_terms_id in zip( self.parent_ip_ids, self.license_terms_ids ): - if not Web3.is_checksum_address(parent_ip_id): + if not validate_address(parent_ip_id): raise ValueError("The parent IP ID must be a valid address.") if not self.ip_asset_registry_client.isRegistered(parent_ip_id): raise ValueError(f"The parent IP ID {parent_ip_id} must be registered.") diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index e5389e8..f1de4cc 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -48,16 +48,6 @@ def create_mock_contract(*args, **kwargs): return mock_web3 -@pytest.fixture(scope="package") -def mock_is_checksum_address(): - def _mock(is_checksum_address: bool = True): - return patch.object( - Web3, "is_checksum_address", return_value=is_checksum_address - ) - - return _mock - - @pytest.fixture(scope="package") def mock_signature_related_methods(): class SignatureMockContext: @@ -71,10 +61,6 @@ def __enter__(self): mock_contract.encode_abi = MagicMock(return_value=b"encoded_data") mock_client.contract = mock_contract - # Create all the patches - mock_web3_to_bytes = patch.object( - Web3, "to_bytes", return_value=b"mock_bytes" - ) mock_account_sign_message = patch.object( Account, "sign_message", @@ -95,13 +81,11 @@ def __init__(self, web3, contract_address=None): ) # Apply all patches at once - mock_web3_to_bytes.start() mock_account_sign_message.start() mock_ip_account_client.start() # Store patches for cleanup self.patches = [ - mock_web3_to_bytes, mock_account_sign_message, mock_ip_account_client, ] diff --git a/tests/unit/utils/test_derivative_data.py b/tests/unit/utils/test_derivative_data.py index 1162df4..0551c51 100644 --- a/tests/unit/utils/test_derivative_data.py +++ b/tests/unit/utils/test_derivative_data.py @@ -114,30 +114,26 @@ def test_validate_parent_ip_ids_and_license_terms_ids_are_not_equal( license_template="0x1234567890123456789012345678901234567890", ) - def test_validate_parent_ip_ids_is_not_valid_address( - self, mock_web3, mock_is_checksum_address - ): - with mock_is_checksum_address(is_checksum_address=False): - with raises(ValueError, match="The parent IP ID must be a valid address."): - DerivativeData( - web3=mock_web3, - parent_ip_ids=["0x1234567890123456789012345678901234567890"], - license_terms_ids=[2], - max_minting_fee=10, - max_rts=10, - max_revenue_share=100, - license_template="0x1234567890123456789012345678901234567890", - ) + def test_validate_parent_ip_ids_is_not_valid_address(self, mock_web3): + with raises( + ValueError, match="Invalid address: 0x12345678901234567890123901234567890." + ): + DerivativeData( + web3=mock_web3, + parent_ip_ids=["0x12345678901234567890123901234567890"], + license_terms_ids=[2], + max_minting_fee=10, + max_rts=10, + max_revenue_share=100, + license_template="0x1234567890123456789012345678901234567890", + ) def test_validate_parent_ip_ids_is_not_registered( self, mock_web3, - mock_is_checksum_address, mock_ip_asset_registry_client, ): - with mock_is_checksum_address(), mock_ip_asset_registry_client( - is_registered=False - ): + with mock_ip_asset_registry_client(is_registered=False): with raises( ValueError, match=f"The parent IP ID {IP_ID} must be registered.", @@ -155,11 +151,10 @@ def test_validate_parent_ip_ids_is_not_registered( def test_validate_license_terms_not_attached( self, mock_web3, - mock_is_checksum_address, mock_ip_asset_registry_client, mock_license_registry_client, ): - with mock_is_checksum_address(), mock_ip_asset_registry_client( + with mock_ip_asset_registry_client( is_registered=True ), mock_license_registry_client(has_ip_attached_license_terms=False): with raises( @@ -179,11 +174,10 @@ def test_validate_license_terms_not_attached( def test_validate_royalty_percent_exceeds_max_revenue_share( self, mock_web3, - mock_is_checksum_address, mock_ip_asset_registry_client, mock_license_registry_client, ): - with mock_is_checksum_address(), mock_ip_asset_registry_client( + with mock_ip_asset_registry_client( is_registered=True ), mock_license_registry_client( has_ip_attached_license_terms=True, get_royalty_percent=1500000000000 @@ -205,11 +199,10 @@ def test_validate_royalty_percent_exceeds_max_revenue_share( def test_validate_royalty_percent_is_less_than_max_revenue_share( self, mock_web3, - mock_is_checksum_address, mock_ip_asset_registry_client, mock_license_registry_client, ): - with mock_is_checksum_address(), mock_ip_asset_registry_client(), mock_license_registry_client(): + with mock_ip_asset_registry_client(), mock_license_registry_client(): derivative_data = DerivativeData.from_input( web3=mock_web3, input_data=DerivativeDataInput( @@ -227,11 +220,10 @@ class TestValidateMaxMintingFee: def test_validate_max_minting_fee_is_less_than_0( self, mock_web3, - mock_is_checksum_address, mock_ip_asset_registry_client, mock_license_registry_client, ): - with mock_is_checksum_address(), mock_ip_asset_registry_client(), mock_license_registry_client(): + with mock_ip_asset_registry_client(), mock_license_registry_client(): with raises( ValueError, match="The max minting fee must be greater than 0." ): @@ -250,11 +242,10 @@ class TestValidateMaxRts: def test_validate_max_rts_is_less_than_0( self, mock_web3, - mock_is_checksum_address, mock_ip_asset_registry_client, mock_license_registry_client, ): - with mock_is_checksum_address(), mock_ip_asset_registry_client(), mock_license_registry_client(): + with mock_ip_asset_registry_client(), mock_license_registry_client(): with raises( ValueError, match="The maxRts must be greater than 0 and less than 100000000.", @@ -288,11 +279,10 @@ def test_validate_max_rts_is_greater_than_100_000_000( def test_validate_max_rts_default_value_is_max_rts( self, mock_web3, - mock_is_checksum_address, mock_ip_asset_registry_client, mock_license_registry_client, ): - with mock_is_checksum_address(), mock_ip_asset_registry_client(), mock_license_registry_client(): + with mock_ip_asset_registry_client(), mock_license_registry_client(): derivative_data = DerivativeData.from_input( web3=mock_web3, input_data=DerivativeDataInput( @@ -325,11 +315,10 @@ def test_validate_max_revenue_share_is_less_than_0( def test_validate_max_revenue_share_is_greater_than_100( self, mock_web3, - mock_is_checksum_address, mock_ip_asset_registry_client, mock_license_registry_client, ): - with mock_is_checksum_address(), mock_ip_asset_registry_client(), mock_license_registry_client(): + with mock_ip_asset_registry_client(), mock_license_registry_client(): with raises( ValueError, match="max_revenue_share must be between 0 and 100." ): @@ -347,11 +336,10 @@ def test_validate_max_revenue_share_is_greater_than_100( def test_validate_max_revenue_share_default_value_is_100( self, mock_web3, - mock_is_checksum_address, mock_ip_asset_registry_client, mock_license_registry_client, ): - with mock_is_checksum_address(), mock_ip_asset_registry_client(), mock_license_registry_client(): + with mock_ip_asset_registry_client(), mock_license_registry_client(): derivative_data = DerivativeData.from_input( web3=mock_web3, input_data=DerivativeDataInput( @@ -366,12 +354,11 @@ class TestValidateLicenseTemplate: def test_validate_license_template_default_value_is_pi_license_template( self, mock_web3, - mock_is_checksum_address, mock_pi_license_template_client, mock_ip_asset_registry_client, mock_license_registry_client, ): - with mock_is_checksum_address(), mock_pi_license_template_client(), mock_ip_asset_registry_client(), mock_license_registry_client(): + with mock_pi_license_template_client(), mock_ip_asset_registry_client(), mock_license_registry_client(): derivative_data = DerivativeData.from_input( web3=mock_web3, input_data=DerivativeDataInput( @@ -386,12 +373,11 @@ class TestGetValidatedData: def test_get_validated_data_with_default_values( self, mock_web3, - mock_is_checksum_address, mock_pi_license_template_client, mock_ip_asset_registry_client, mock_license_registry_client, ): - with mock_is_checksum_address(), mock_pi_license_template_client(), mock_ip_asset_registry_client(), mock_license_registry_client(): + with mock_pi_license_template_client(), mock_ip_asset_registry_client(), mock_license_registry_client(): derivative_data = DerivativeData.from_input( web3=mock_web3, input_data=DerivativeDataInput( @@ -415,9 +401,8 @@ def test_get_validated_data_with_custom_values( mock_ip_asset_registry_client, mock_license_registry_client, mock_pi_license_template_client, - mock_is_checksum_address, ): - with mock_is_checksum_address(), mock_pi_license_template_client(), mock_ip_asset_registry_client(), mock_license_registry_client(): + with mock_pi_license_template_client(), mock_ip_asset_registry_client(), mock_license_registry_client(): derivative_data = DerivativeData( web3=mock_web3, parent_ip_ids=[IP_ID], From 86e1ba29e4ac321f0e748364b844cc1b09d241fc Mon Sep 17 00:00:00 2001 From: Bonnie Date: Thu, 11 Dec 2025 15:51:01 +0800 Subject: [PATCH 12/21] feat: integrate ModuleRegistryClient into IPAsset and update licensing configuration validation logic --- .../resources/IPAsset.py | 10 +- .../utils/licensing_config_data.py | 5 +- .../utils/royalty.py | 5 - tests/unit/fixtures/data.py | 1 + tests/unit/resources/test_ip_asset.py | 97 +++++++++++++++++-- tests/unit/resources/test_license.py | 59 +++++++---- 6 files changed, 140 insertions(+), 37 deletions(-) diff --git a/src/story_protocol_python_sdk/resources/IPAsset.py b/src/story_protocol_python_sdk/resources/IPAsset.py index 6ff3fa3..a885520 100644 --- a/src/story_protocol_python_sdk/resources/IPAsset.py +++ b/src/story_protocol_python_sdk/resources/IPAsset.py @@ -37,6 +37,9 @@ from story_protocol_python_sdk.abi.LicensingModule.LicensingModule_client import ( LicensingModuleClient, ) +from story_protocol_python_sdk.abi.ModuleRegistry.ModuleRegistry_client import ( + ModuleRegistryClient, +) from story_protocol_python_sdk.abi.Multicall3.Multicall3_client import Multicall3Client from story_protocol_python_sdk.abi.PILicenseTemplate.PILicenseTemplate_client import ( PILicenseTemplateClient, @@ -88,6 +91,7 @@ is_initial_ip_metadata, ) from story_protocol_python_sdk.utils.license_terms import LicenseTerms +from story_protocol_python_sdk.utils.licensing_config_data import LicensingConfigData from story_protocol_python_sdk.utils.pil_flavor import PILFlavor from story_protocol_python_sdk.utils.royalty import get_royalty_shares from story_protocol_python_sdk.utils.sign import Sign @@ -134,6 +138,7 @@ def __init__(self, web3: Web3, account, chain_id: int): self.multicall3_client = Multicall3Client(web3) self.license_terms_util = LicenseTerms(web3) self.sign_util = Sign(web3, self.chain_id, self.account) + self.module_registry_client = ModuleRegistryClient(web3) def mint( self, @@ -689,7 +694,6 @@ def register_ip_and_attach_pil_terms( 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) # Get permission signature for all required permissions @@ -2196,8 +2200,8 @@ def _validate_license_terms_data( validated_license_terms_data.append( { "terms": convert_dict_keys_to_camel_case(asdict(license_terms)), - "licensingConfig": self.license_terms_util.validate_licensing_config( - licensing_config_dict + "licensingConfig": LicensingConfigData.validate_license_config( + self.module_registry_client, licensing_config_dict ), } ) diff --git a/src/story_protocol_python_sdk/utils/licensing_config_data.py b/src/story_protocol_python_sdk/utils/licensing_config_data.py index abe3a8a..910a5b6 100644 --- a/src/story_protocol_python_sdk/utils/licensing_config_data.py +++ b/src/story_protocol_python_sdk/utils/licensing_config_data.py @@ -2,6 +2,7 @@ from typing import TypedDict from ens.ens import Address, HexStr +from web3 import Web3 from story_protocol_python_sdk.abi.ModuleRegistry.ModuleRegistry_client import ( ModuleRegistryClient, @@ -37,7 +38,7 @@ class LicensingConfig(TypedDict): is_set: bool minting_fee: int licensing_hook: Address - hook_data: HexStr + hook_data: str commercial_rev_share: int disabled: bool expect_minimum_group_reward_share: int @@ -131,7 +132,7 @@ def validate_license_config( isSet=licensing_config["is_set"], mintingFee=licensing_config["minting_fee"], licensingHook=validate_address(licensing_config["licensing_hook"]), - hookData=licensing_config["hook_data"], + hookData=Web3.to_bytes(hexstr=HexStr(licensing_config["hook_data"])), commercialRevShare=get_revenue_share( licensing_config["commercial_rev_share"] ), diff --git a/src/story_protocol_python_sdk/utils/royalty.py b/src/story_protocol_python_sdk/utils/royalty.py index e1f122a..acea2a3 100644 --- a/src/story_protocol_python_sdk/utils/royalty.py +++ b/src/story_protocol_python_sdk/utils/royalty.py @@ -80,11 +80,6 @@ def royalty_policy_input_to_address( Raises: ValueError: If the custom address is invalid. """ - print("--------------------------------") - print(input) - print(input == NativeRoyaltyPolicy.LAP) - print(input == NativeRoyaltyPolicy.LRP) - print("--------------------------------") if input is None: return ROYALTY_POLICY_LAP_ADDRESS diff --git a/tests/unit/fixtures/data.py b/tests/unit/fixtures/data.py index bfca21d..73155bf 100644 --- a/tests/unit/fixtures/data.py +++ b/tests/unit/fixtures/data.py @@ -21,6 +21,7 @@ "expiration": 100, "commercial_use": True, "commercial_attribution": True, + "commercial_rev_ceiling": 0, "commercializer_checker": True, "commercializer_checker_data": ADDRESS, "derivatives_allowed": True, diff --git a/tests/unit/resources/test_ip_asset.py b/tests/unit/resources/test_ip_asset.py index 16ca846..a00620d 100644 --- a/tests/unit/resources/test_ip_asset.py +++ b/tests/unit/resources/test_ip_asset.py @@ -6,8 +6,13 @@ from story_protocol_python_sdk import ( MAX_ROYALTY_TOKEN, + LicenseTermsDataInput, + LicenseTermsOverride, MintedNFT, MintNFT, + NativeRoyaltyPolicy, + PILFlavor, + PILFlavorError, RoyaltyShareInput, ) from story_protocol_python_sdk.abi.IPAccountImpl.IPAccountImpl_client import ( @@ -298,7 +303,7 @@ def test_royalty_policy_commercial_rev_share_is_less_than_0( ): with mock_get_ip_id(), mock_is_registered(): with pytest.raises( - ValueError, match="commercial_rev_share should be between 0 and 100." + PILFlavorError, match="commercial_rev_share must be between 0 and 100." ): ip_asset.register_ip_and_attach_pil_terms( nft_contract=ADDRESS, @@ -334,7 +339,6 @@ def test_transaction_to_be_called_with_correct_parameters( ip_asset.license_attachment_workflows_client, "build_registerIpAndAttachPILTerms_transaction", ) as mock_build_registerIpAndAttachPILTerms_transaction: - ip_asset.register_ip_and_attach_pil_terms( nft_contract=ADDRESS, token_id=3, @@ -359,7 +363,7 @@ def test_transaction_to_be_called_with_correct_parameters( "commercialUse": True, "commercialAttribution": True, "commercializerChecker": True, - "commercializerCheckerData": b"mock_bytes", + "commercializerCheckerData": "0x1234567890123456789012345678901234567890", "commercialRevShare": 19000000, "commercialRevCeiling": 0, "derivativesAllowed": True, @@ -373,7 +377,7 @@ def test_transaction_to_be_called_with_correct_parameters( "licensingConfig": { "isSet": True, "mintingFee": 10, - "hookData": b"mock_bytes", + "hookData": Web3.to_bytes(hexstr=HexStr(ADDRESS)), "licensingHook": "0x1234567890123456789012345678901234567890", "commercialRevShare": 10000000, "disabled": False, @@ -1003,7 +1007,7 @@ def test_successful_registration( "commercialUse": True, "commercialAttribution": True, "commercializerChecker": True, - "commercializerCheckerData": b"mock_bytes", + "commercializerCheckerData": "0x1234567890123456789012345678901234567890", "commercialRevShare": 19000000, "commercialRevCeiling": 0, "derivativesAllowed": True, @@ -1017,7 +1021,7 @@ def test_successful_registration( "licensingConfig": { "isSet": True, "mintingFee": 10, - "hookData": b"mock_bytes", + "hookData": Web3.to_bytes(hexstr=HexStr(ADDRESS)), "licensingHook": "0x1234567890123456789012345678901234567890", "commercialRevShare": 10000000, "disabled": False, @@ -2377,6 +2381,87 @@ def test_success_when_license_terms_data_provided_for_minted_nft( assert result["token_id"] == 3 assert result["license_terms_ids"] is not None + def test_success_when_license_terms_data_is_generated_by_pil_flavor_for_minted_nft( + self, + ip_asset: IPAsset, + mock_parse_ip_registered_event, + mock_parse_tx_license_terms_attached_event, + mock_get_ip_id, + mock_signature_related_methods, + mock_is_registered, + ): + with ( + mock_parse_ip_registered_event(), + mock_parse_tx_license_terms_attached_event(), + mock_get_ip_id(), + mock_signature_related_methods(), + mock_is_registered(is_registered=False), + patch.object( + ip_asset.license_attachment_workflows_client, + "build_registerIpAndAttachPILTerms_transaction", + return_value={"tx_hash": TX_HASH.hex()}, + ) as mock_build_register_transaction, + ): + ip_asset.register_ip_asset( + nft=MintedNFT(type="minted", nft_contract=ADDRESS, token_id=3), + license_terms_data=[ + LicenseTermsDataInput( + terms=PILFlavor.commercial_use( + default_minting_fee=10, + currency=ADDRESS, + override=LicenseTermsOverride( + commercial_rev_share=10, + royalty_policy=NativeRoyaltyPolicy.LAP, + ), + ), + licensing_config={ + "is_set": True, + "minting_fee": 10, + "licensing_hook": ADDRESS, + "hook_data": "11", + "commercial_rev_share": 10, + "disabled": False, + "expect_minimum_group_reward_share": 0, + "expect_group_reward_pool": ZERO_ADDRESS, + }, + ) + ], + ip_metadata=IP_METADATA, + ) + assert mock_build_register_transaction.call_args[0][3] == [ + { + "terms": { + "transferable": True, + "commercialAttribution": True, + "commercialRevCeiling": 0, + "commercialRevShare": 10 * 10**6, + "commercialUse": True, + "commercializerChecker": ZERO_ADDRESS, + "commercializerCheckerData": ZERO_ADDRESS, + "currency": ADDRESS, + "derivativeRevCeiling": 0, + "derivativesAllowed": False, + "derivativesApproval": False, + "derivativesAttribution": False, + "derivativesReciprocal": False, + "expiration": 0, + "defaultMintingFee": 10, + "royaltyPolicy": "0xBe54FB168b3c982b7AaE60dB6CF75Bd8447b390E", + "uri": "https://github.com/piplabs/pil-document/blob/9a1f803fcf8101a8a78f1dcc929e6014e144ab56/off-chain-terms/CommercialUse.json", + }, + "licensingConfig": { + "isSet": True, + "mintingFee": 10, + "hookData": Web3.to_bytes(hexstr=HexStr("11")), + "licensingHook": ADDRESS, + "commercialRevShare": 10 * 10**6, + "disabled": False, + "expectMinimumGroupRewardShare": 0, + "expectGroupRewardPool": ZERO_ADDRESS, + }, + } + ] + def test_success_when_ip_metadata_provided_for_minted_nft( self, ip_asset: IPAsset, diff --git a/tests/unit/resources/test_license.py b/tests/unit/resources/test_license.py index c1a0e74..d81fd1c 100644 --- a/tests/unit/resources/test_license.py +++ b/tests/unit/resources/test_license.py @@ -4,6 +4,7 @@ import pytest from _pytest.fixtures import fixture +from ens.ens import HexStr from web3 import Web3 from story_protocol_python_sdk import ( @@ -14,6 +15,7 @@ PILFlavor, PILFlavorError, ) +from story_protocol_python_sdk.utils.util import convert_dict_keys_to_camel_case from tests.unit.fixtures.data import ADDRESS, CHAIN_ID, IP_ID, TX_HASH from tests.unit.resources.test_ip_account import ZERO_HASH @@ -93,9 +95,9 @@ def test_register_pil_terms_success(self, license: License): uri="", ) assert ( - mock_build_registerLicenseTerms_transaction.call_args[0][ - 0 - ].commercial_rev_share + mock_build_registerLicenseTerms_transaction.call_args[0][0][ + "commercialRevShare" + ] == 90 * 10**6 ) assert "tx_hash" in response @@ -206,9 +208,10 @@ def test_register_non_commercial_social_remixing_pil_success( license.register_pil_terms( **asdict(PILFlavor.non_commercial_social_remixing()) ) - assert ( - mock_build_registerLicenseTerms_transaction.call_args[0][0] - == PILFlavor.non_commercial_social_remixing() + assert mock_build_registerLicenseTerms_transaction.call_args[0][ + 0 + ] == convert_dict_keys_to_camel_case( + asdict(PILFlavor.non_commercial_social_remixing()) ) def test_register_commercial_remix_pil_success(self, license: License): @@ -242,13 +245,19 @@ def test_register_commercial_remix_pil_success(self, license: License): ) ) ) - assert mock_build_registerLicenseTerms_transaction.call_args[0][0] == replace( - PILFlavor.commercial_remix( - default_minting_fee=1513, - currency=ADDRESS, - commercial_rev_share=90, - ), - commercial_rev_share=90 * 10**6, + assert mock_build_registerLicenseTerms_transaction.call_args[0][ + 0 + ] == convert_dict_keys_to_camel_case( + asdict( + replace( + PILFlavor.commercial_remix( + default_minting_fee=1513, + currency=ADDRESS, + commercial_rev_share=90, + ), + commercial_rev_share=90 * 10**6, + ) + ) ) def test_register_commercial_use_pil_success(self, license: License): @@ -283,9 +292,13 @@ def test_register_commercial_use_pil_success(self, license: License): ) assert mock_build_registerLicenseTerms_transaction.call_args[0][ 0 - ] == PILFlavor.commercial_use( - default_minting_fee=1513, - currency=ADDRESS, + ] == convert_dict_keys_to_camel_case( + asdict( + PILFlavor.commercial_use( + default_minting_fee=1513, + currency=ADDRESS, + ) + ) ) def test_register_creative_commons_attribution_pil_success(self, license: License): @@ -319,8 +332,12 @@ def test_register_creative_commons_attribution_pil_success(self, license: Licens ) assert mock_build_registerLicenseTerms_transaction.call_args[0][ 0 - ] == PILFlavor.creative_commons_attribution( - currency=ADDRESS, + ] == convert_dict_keys_to_camel_case( + asdict( + PILFlavor.creative_commons_attribution( + currency=ADDRESS, + ) + ) ) @@ -1106,7 +1123,7 @@ def test_set_licensing_config_success_with_default_template( is_set=True, minting_fee=1, licensing_hook=ZERO_ADDRESS, - hook_data=ZERO_HASH, + hook_data="test", commercial_rev_share=10, disabled=False, expect_minimum_group_reward_share=100, @@ -1124,7 +1141,7 @@ def test_set_licensing_config_success_with_default_template( "isSet": True, "mintingFee": 1, "licensingHook": ZERO_ADDRESS, - "hookData": ZERO_HASH, + "hookData": Web3.to_bytes(hexstr=HexStr("test")), "commercialRevShare": 10 * 10**6, "disabled": False, "expectMinimumGroupRewardShare": 100 * 10**6, @@ -1164,7 +1181,7 @@ def test_set_licensing_config_success_with_custom_template( "isSet": True, "mintingFee": 1, "licensingHook": ZERO_ADDRESS, - "hookData": "0x", + "hookData": Web3.to_bytes(hexstr=HexStr("0x")), "commercialRevShare": 0, "disabled": False, "expectMinimumGroupRewardShare": 0, From f6807e8cfe91ca009765b776c9f1ba20ec01ad7b Mon Sep 17 00:00:00 2001 From: Bonnie Date: Thu, 11 Dec 2025 16:03:52 +0800 Subject: [PATCH 13/21] fix: update hookData handling in LicensingConfigData to use text conversion and adjust related tests for consistency --- .../utils/licensing_config_data.py | 6 +++++- tests/unit/fixtures/data.py | 2 +- tests/unit/resources/test_ip_asset.py | 6 +++--- tests/unit/resources/test_license.py | 9 ++++----- 4 files changed, 13 insertions(+), 10 deletions(-) diff --git a/src/story_protocol_python_sdk/utils/licensing_config_data.py b/src/story_protocol_python_sdk/utils/licensing_config_data.py index 910a5b6..c1b0e89 100644 --- a/src/story_protocol_python_sdk/utils/licensing_config_data.py +++ b/src/story_protocol_python_sdk/utils/licensing_config_data.py @@ -132,7 +132,11 @@ def validate_license_config( isSet=licensing_config["is_set"], mintingFee=licensing_config["minting_fee"], licensingHook=validate_address(licensing_config["licensing_hook"]), - hookData=Web3.to_bytes(hexstr=HexStr(licensing_config["hook_data"])), + hookData=( + Web3.to_bytes(text=licensing_config["hook_data"]) + if licensing_config["hook_data"] != ZERO_HASH + else ZERO_HASH + ), commercialRevShare=get_revenue_share( licensing_config["commercial_rev_share"] ), diff --git a/tests/unit/fixtures/data.py b/tests/unit/fixtures/data.py index 73155bf..136ded9 100644 --- a/tests/unit/fixtures/data.py +++ b/tests/unit/fixtures/data.py @@ -37,7 +37,7 @@ "is_set": True, "minting_fee": 10, "licensing_hook": ADDRESS, - "hook_data": ADDRESS, + "hook_data": "test", "commercial_rev_share": 10, "disabled": False, "expect_minimum_group_reward_share": 10, diff --git a/tests/unit/resources/test_ip_asset.py b/tests/unit/resources/test_ip_asset.py index a00620d..be776b3 100644 --- a/tests/unit/resources/test_ip_asset.py +++ b/tests/unit/resources/test_ip_asset.py @@ -377,7 +377,7 @@ def test_transaction_to_be_called_with_correct_parameters( "licensingConfig": { "isSet": True, "mintingFee": 10, - "hookData": Web3.to_bytes(hexstr=HexStr(ADDRESS)), + "hookData": Web3.to_bytes(text="test"), "licensingHook": "0x1234567890123456789012345678901234567890", "commercialRevShare": 10000000, "disabled": False, @@ -1021,7 +1021,7 @@ def test_successful_registration( "licensingConfig": { "isSet": True, "mintingFee": 10, - "hookData": Web3.to_bytes(hexstr=HexStr(ADDRESS)), + "hookData": Web3.to_bytes(text="test"), "licensingHook": "0x1234567890123456789012345678901234567890", "commercialRevShare": 10000000, "disabled": False, @@ -2452,7 +2452,7 @@ def test_success_when_license_terms_data_is_generated_by_pil_flavor_for_minted_n "licensingConfig": { "isSet": True, "mintingFee": 10, - "hookData": Web3.to_bytes(hexstr=HexStr("11")), + "hookData": Web3.to_bytes(text="11"), "licensingHook": ADDRESS, "commercialRevShare": 10 * 10**6, "disabled": False, diff --git a/tests/unit/resources/test_license.py b/tests/unit/resources/test_license.py index d81fd1c..73c47d2 100644 --- a/tests/unit/resources/test_license.py +++ b/tests/unit/resources/test_license.py @@ -4,7 +4,6 @@ import pytest from _pytest.fixtures import fixture -from ens.ens import HexStr from web3 import Web3 from story_protocol_python_sdk import ( @@ -791,7 +790,7 @@ def default_licensing_config() -> LicensingConfig: "is_set": True, "minting_fee": 1, "licensing_hook": ZERO_ADDRESS, - "hook_data": "0x", + "hook_data": ZERO_HASH, "commercial_rev_share": 0, "disabled": False, "expect_minimum_group_reward_share": 0, @@ -1123,7 +1122,7 @@ def test_set_licensing_config_success_with_default_template( is_set=True, minting_fee=1, licensing_hook=ZERO_ADDRESS, - hook_data="test", + hook_data=ZERO_HASH, commercial_rev_share=10, disabled=False, expect_minimum_group_reward_share=100, @@ -1141,7 +1140,7 @@ def test_set_licensing_config_success_with_default_template( "isSet": True, "mintingFee": 1, "licensingHook": ZERO_ADDRESS, - "hookData": Web3.to_bytes(hexstr=HexStr("test")), + "hookData": ZERO_HASH, "commercialRevShare": 10 * 10**6, "disabled": False, "expectMinimumGroupRewardShare": 100 * 10**6, @@ -1181,7 +1180,7 @@ def test_set_licensing_config_success_with_custom_template( "isSet": True, "mintingFee": 1, "licensingHook": ZERO_ADDRESS, - "hookData": Web3.to_bytes(hexstr=HexStr("0x")), + "hookData": ZERO_HASH, "commercialRevShare": 0, "disabled": False, "expectMinimumGroupRewardShare": 0, From 255d01004bf753f317c1902cbd262521ff564ee2 Mon Sep 17 00:00:00 2001 From: Bonnie Date: Thu, 11 Dec 2025 16:09:03 +0800 Subject: [PATCH 14/21] refactor: remove LicenseTerms class and integrate LicensingConfigData into Group and IPAsset for improved licensing configuration management --- .../resources/Group.py | 12 +- .../resources/IPAsset.py | 2 - .../utils/license_terms.py | 288 ------------------ .../unit/utils/test_licensing_config_data.py | 35 ++- 4 files changed, 40 insertions(+), 297 deletions(-) delete mode 100644 src/story_protocol_python_sdk/utils/license_terms.py diff --git a/src/story_protocol_python_sdk/resources/Group.py b/src/story_protocol_python_sdk/resources/Group.py index 3166f05..1789f8b 100644 --- a/src/story_protocol_python_sdk/resources/Group.py +++ b/src/story_protocol_python_sdk/resources/Group.py @@ -22,6 +22,9 @@ from story_protocol_python_sdk.abi.LicensingModule.LicensingModule_client import ( LicensingModuleClient, ) +from story_protocol_python_sdk.abi.ModuleRegistry.ModuleRegistry_client import ( + ModuleRegistryClient, +) from story_protocol_python_sdk.abi.PILicenseTemplate.PILicenseTemplate_client import ( PILicenseTemplateClient, ) @@ -32,7 +35,7 @@ CollectRoyaltiesResponse, ) from story_protocol_python_sdk.utils.constants import ZERO_ADDRESS, ZERO_HASH -from story_protocol_python_sdk.utils.license_terms import LicenseTerms +from story_protocol_python_sdk.utils.licensing_config_data import LicensingConfigData 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 get_revenue_share @@ -59,8 +62,7 @@ def __init__(self, web3: Web3, account, chain_id: int): self.licensing_module_client = LicensingModuleClient(web3) self.license_registry_client = LicenseRegistryClient(web3) self.pi_license_template_client = PILicenseTemplateClient(web3) - - self.license_terms_util = LicenseTerms(web3) + self.module_registry_client = ModuleRegistryClient(web3) self.sign_util = Sign(web3, self.chain_id, self.account) def register_group(self, group_pool: str, tx_options: dict | None = None) -> dict: @@ -707,8 +709,8 @@ def _get_license_data(self, license_data: list) -> list: processed_item = { "licenseTemplate": license_template, "licenseTermsId": item["license_terms_id"], - "licensingConfig": self.license_terms_util.validate_licensing_config( - item.get("licensing_config", {}) + "licensingConfig": LicensingConfigData.validate_license_config( + self.module_registry_client, item.get("licensing_config", {}) ), } diff --git a/src/story_protocol_python_sdk/resources/IPAsset.py b/src/story_protocol_python_sdk/resources/IPAsset.py index a885520..4be3f66 100644 --- a/src/story_protocol_python_sdk/resources/IPAsset.py +++ b/src/story_protocol_python_sdk/resources/IPAsset.py @@ -90,7 +90,6 @@ get_ip_metadata_dict, is_initial_ip_metadata, ) -from story_protocol_python_sdk.utils.license_terms import LicenseTerms from story_protocol_python_sdk.utils.licensing_config_data import LicensingConfigData from story_protocol_python_sdk.utils.pil_flavor import PILFlavor from story_protocol_python_sdk.utils.royalty import get_royalty_shares @@ -136,7 +135,6 @@ def __init__(self, web3: Web3, account, chain_id: int): ) self.royalty_module_client = RoyaltyModuleClient(web3) self.multicall3_client = Multicall3Client(web3) - self.license_terms_util = LicenseTerms(web3) self.sign_util = Sign(web3, self.chain_id, self.account) self.module_registry_client = ModuleRegistryClient(web3) diff --git a/src/story_protocol_python_sdk/utils/license_terms.py b/src/story_protocol_python_sdk/utils/license_terms.py deleted file mode 100644 index 7b190b3..0000000 --- a/src/story_protocol_python_sdk/utils/license_terms.py +++ /dev/null @@ -1,288 +0,0 @@ -# src/story_protocol_python_sdk/utils/license_terms.py - -from ens.async_ens import HexStr -from web3 import Web3 - -from story_protocol_python_sdk.abi.RoyaltyModule.RoyaltyModule_client import ( - RoyaltyModuleClient, -) -from story_protocol_python_sdk.types.common import RevShareType -from story_protocol_python_sdk.utils.constants import ( - ROYALTY_POLICY_LAP_ADDRESS, - ZERO_ADDRESS, -) -from story_protocol_python_sdk.utils.validation import get_revenue_share - - -class LicenseTerms: - def __init__(self, web3: Web3): - self.web3 = web3 - self.royalty_module_client = RoyaltyModuleClient(web3) - - PIL_TYPE = { - "NON_COMMERCIAL_REMIX": "non_commercial_remix", - "COMMERCIAL_USE": "commercial_use", - "COMMERCIAL_REMIX": "commercial_remix", - } - - def get_license_term_by_type(self, type, term=None): - license_terms = { - "transferable": True, - "royaltyPolicy": "0x0000000000000000000000000000000000000000", - "defaultMintingFee": 0, - "expiration": 0, - "commercialUse": False, - "commercialAttribution": False, - "commercializerChecker": "0x0000000000000000000000000000000000000000", - "commercializerCheckerData": "0x0000000000000000000000000000000000000000", - "commercialRevShare": 0, - "commercialRevCeiling": 0, - "derivativesAllowed": True, - "derivativesAttribution": True, - "derivativesApproval": False, - "derivativesReciprocal": True, - "derivativeRevCeiling": 0, - "currency": "0x0000000000000000000000000000000000000000", - "uri": "", - } - - if type == self.PIL_TYPE["NON_COMMERCIAL_REMIX"]: - license_terms["commercializerCheckerData"] = "0x" - return license_terms - elif type == self.PIL_TYPE["COMMERCIAL_USE"]: - if not term or "defaultMintingFee" not in term or "currency" not in term: - raise ValueError( - "DefaultMintingFee, currency are required for commercial use PIL." - ) - - if term["royaltyPolicyAddress"] is None: - term["royaltyPolicyAddress"] = ROYALTY_POLICY_LAP_ADDRESS - - license_terms.update( - { - "defaultMintingFee": int(term["defaultMintingFee"]), - "currency": term["currency"], - "commercialUse": True, - "commercialAttribution": True, - "derivativesReciprocal": False, - "royaltyPolicy": term["royaltyPolicyAddress"], - } - ) - return license_terms - else: - if ( - not term - or "defaultMintingFee" not in term - or "currency" not in term - or "commercialRevShare" not in term - ): - raise ValueError( - "DefaultMintingFee, currency and commercialRevShare are required for commercial remix PIL." - ) - - if "royaltyPolicyAddress" not in term: - raise ValueError("royaltyPolicyAddress is required") - - if term["commercialRevShare"] < 0 or term["commercialRevShare"] > 100: - raise ValueError("CommercialRevShare should be between 0 and 100.") - - license_terms.update( - { - "defaultMintingFee": int(term["defaultMintingFee"]), - "currency": term["currency"], - "commercialUse": True, - "commercialAttribution": True, - "commercialRevShare": get_revenue_share(term["commercialRevShare"]), - "derivativesReciprocal": True, - "royaltyPolicy": term["royaltyPolicyAddress"], - } - ) - return license_terms - - def validate_license_terms(self, params): - royalty_policy = params.get("royalty_policy") - currency = params.get("currency") - if royalty_policy != ZERO_ADDRESS: - is_whitelisted = self.royalty_module_client.isWhitelistedRoyaltyPolicy( - royalty_policy - ) - if not is_whitelisted: - raise ValueError("The royalty policy is not whitelisted.") - - if currency != ZERO_ADDRESS: - is_whitelisted = self.royalty_module_client.isWhitelistedRoyaltyToken( - currency - ) - if not is_whitelisted: - raise ValueError("The currency token is not whitelisted.") - - if royalty_policy != ZERO_ADDRESS and currency == ZERO_ADDRESS: - raise ValueError("Royalty policy requires currency token.") - - commercial_rev_share = params.get("commercial_rev_share", 0) - if commercial_rev_share < 0 or commercial_rev_share > 100: - raise ValueError("commercial_rev_share should be between 0 and 100.") - - validated_params = { - "transferable": params.get("transferable"), - "royaltyPolicy": params.get("royalty_policy"), - "defaultMintingFee": int(params.get("default_minting_fee", 0)), - "expiration": int(params.get("expiration", 0)), - "commercialUse": params.get("commercial_use"), - "commercialAttribution": params.get("commercial_attribution"), - "commercializerChecker": params.get("commercializer_checker"), - "commercializerCheckerData": Web3.to_bytes( - hexstr=HexStr(params.get("commercializer_checker_data", ZERO_ADDRESS)) - ), - "commercialRevShare": get_revenue_share( - params.get("commercial_rev_share", 0) - ), - "commercialRevCeiling": int(params.get("commercial_rev_ceiling", 0)), - "derivativesAllowed": params.get("derivatives_allowed"), - "derivativesAttribution": params.get("derivatives_attribution"), - "derivativesApproval": params.get("derivatives_approval"), - "derivativesReciprocal": params.get("derivatives_reciprocal"), - "derivativeRevCeiling": int(params.get("derivative_rev_ceiling", 0)), - "currency": params.get("currency"), - "uri": params.get("uri"), - } - - self.verify_commercial_use(validated_params) - self.verify_derivatives(validated_params) - return validated_params - - def validate_licensing_config(self, params): - if not isinstance(params, dict): - raise TypeError("Licensing config parameters must be a dictionary") - - required_params = { - "is_set": bool, - "minting_fee": int, - "hook_data": str, - "licensing_hook": str, - "commercial_rev_share": int, - "disabled": bool, - "expect_minimum_group_reward_share": int, - "expect_group_reward_pool": str, - } - - for param, expected_type in required_params.items(): - if param in params: - if not isinstance(params[param], expected_type): - raise TypeError(f"{param} must be of type {expected_type.__name__}") - - default_params = { - "isSet": False, - "mintingFee": 0, - "hookData": ZERO_ADDRESS, - "licensingHook": ZERO_ADDRESS, - "commercialRevShare": 0, - "disabled": False, - "expectMinimumGroupRewardShare": 0, - "expectGroupRewardPool": ZERO_ADDRESS, - } - - if not params.get("is_set", False): - return default_params - - if params.get("minting_fee", 0) < 0: - raise ValueError("Minting fee cannot be negative") - - if ( - params.get("commercial_rev_share", 0) < 0 - or params.get("commercial_rev_share", 0) > 100 - ): - raise ValueError("Commercial revenue share must be between 0 and 100") - if ( - params.get("expect_minimum_group_reward_share", 0) < 0 - or params.get("expect_minimum_group_reward_share", 0) > 100 - ): - raise ValueError( - "Expect minimum group reward share must be between 0 and 100" - ) - validated_params = { - "isSet": params.get("is_set", False), - "mintingFee": params.get("minting_fee", 0), - "hookData": Web3.to_bytes(hexstr=HexStr(params["hook_data"])), - "licensingHook": params.get("licensing_hook", ZERO_ADDRESS), - "commercialRevShare": get_revenue_share(params["commercial_rev_share"]), - "disabled": params.get("disabled", False), - "expectMinimumGroupRewardShare": get_revenue_share( - params["expect_minimum_group_reward_share"], - RevShareType.EXPECT_MINIMUM_GROUP_REWARD_SHARE, - ), - "expectGroupRewardPool": params.get( - "expect_group_reward_pool", ZERO_ADDRESS - ), - } - - return validated_params - - def verify_commercial_use(self, terms): - if not terms.get("commercialUse", False): - if terms.get("commercialAttribution", False): - raise ValueError( - "Cannot add commercial attribution when commercial use is disabled." - ) - if terms.get("commercializerChecker") != ZERO_ADDRESS: - raise ValueError( - "Cannot add commercializerChecker when commercial use is disabled." - ) - if terms.get("commercialRevShare", 0) > 0: - raise ValueError( - "Cannot add commercial revenue share when commercial use is disabled." - ) - if terms.get("commercialRevCeiling", 0) > 0: - raise ValueError( - "Cannot add commercial revenue ceiling when commercial use is disabled." - ) - if terms.get("derivativeRevCeiling", 0) > 0: - raise ValueError( - "Cannot add derivative revenue ceiling when commercial use is disabled." - ) - if terms.get("royaltyPolicy") != ZERO_ADDRESS: - raise ValueError( - "Cannot add commercial royalty policy when commercial use is disabled." - ) - else: - if terms.get("royaltyPolicy") == ZERO_ADDRESS: - raise ValueError( - "Royalty policy is required when commercial use is enabled." - ) - - def verify_derivatives(self, terms): - if not terms.get("derivativesAllowed", False): - if terms.get("derivativesAttribution", False): - raise ValueError( - "Cannot add derivative attribution when derivative use is disabled." - ) - if terms.get("derivativesApproval", False): - raise ValueError( - "Cannot add derivative approval when derivative use is disabled." - ) - if terms.get("derivativesReciprocal", False): - raise ValueError( - "Cannot add derivative reciprocal when derivative use is disabled." - ) - if terms.get("derivativeRevCeiling", 0) > 0: - raise ValueError( - "Cannot add derivative revenue ceiling when derivative use is disabled." - ) - - def get_revenue_share(self, rev_share: int | str) -> int: - """ - Convert revenue share percentage to token amount. - - :param rev_share int|str: Revenue share percentage between 0-100 - :return int: Revenue share token amount - """ - try: - rev_share_number = float(rev_share) - except ValueError: - raise ValueError("CommercialRevShare must be a valid number.") - - if rev_share_number < 0 or rev_share_number > 100: - raise ValueError("CommercialRevShare should be between 0 and 100.") - - MAX_ROYALTY_TOKEN = 100000000 - return int((rev_share_number / 100) * MAX_ROYALTY_TOKEN) diff --git a/tests/unit/utils/test_licensing_config_data.py b/tests/unit/utils/test_licensing_config_data.py index 46ea42d..42918be 100644 --- a/tests/unit/utils/test_licensing_config_data.py +++ b/tests/unit/utils/test_licensing_config_data.py @@ -1,6 +1,7 @@ from unittest.mock import MagicMock, Mock import pytest +from web3 import Web3 from story_protocol_python_sdk.utils.constants import ZERO_ADDRESS, ZERO_HASH from story_protocol_python_sdk.utils.licensing_config_data import ( @@ -44,7 +45,37 @@ def test_validate_license_config_valid_input(self, mock_module_registry_client): "is_set": True, "minting_fee": 100, "licensing_hook": ZERO_ADDRESS, - "hook_data": "0xabcdef", + "hook_data": ZERO_HASH, + "commercial_rev_share": 50, + "disabled": False, + "expect_minimum_group_reward_share": 25, + "expect_group_reward_pool": ZERO_ADDRESS, + } + + result = LicensingConfigData.validate_license_config( + mock_module_registry_client(), input_config + ) + + assert result == ValidatedLicensingConfig( + isSet=True, + mintingFee=100, + licensingHook=ZERO_ADDRESS, + hookData=ZERO_HASH, + commercialRevShare=50 * 10**6, + disabled=False, + expectMinimumGroupRewardShare=25 * 10**6, + expectGroupRewardPool=ZERO_ADDRESS, + ) + + def test_validate_license_config_valid_input_with_custom_hook_data( + self, mock_module_registry_client + ): + """Test validate_license_config with valid input.""" + input_config: LicensingConfig = { + "is_set": True, + "minting_fee": 100, + "licensing_hook": ZERO_ADDRESS, + "hook_data": "test", "commercial_rev_share": 50, "disabled": False, "expect_minimum_group_reward_share": 25, @@ -59,7 +90,7 @@ def test_validate_license_config_valid_input(self, mock_module_registry_client): isSet=True, mintingFee=100, licensingHook=ZERO_ADDRESS, - hookData="0xabcdef", + hookData=Web3.to_bytes(text="test"), commercialRevShare=50 * 10**6, disabled=False, expectMinimumGroupRewardShare=25 * 10**6, From 0b6fa8e6b0204aeb0d2d98dcff96c7b0aae770d0 Mon Sep 17 00:00:00 2001 From: Bonnie Date: Thu, 11 Dec 2025 16:23:15 +0800 Subject: [PATCH 15/21] feat: add comprehensive tests for various licensing configurations in IPAsset, including commercial remix and non-commercial terms --- tests/unit/resources/test_ip_asset.py | 204 +++++++++++++++++++++++++- 1 file changed, 201 insertions(+), 3 deletions(-) diff --git a/tests/unit/resources/test_ip_asset.py b/tests/unit/resources/test_ip_asset.py index be776b3..896f506 100644 --- a/tests/unit/resources/test_ip_asset.py +++ b/tests/unit/resources/test_ip_asset.py @@ -8,6 +8,7 @@ MAX_ROYALTY_TOKEN, LicenseTermsDataInput, LicenseTermsOverride, + LicensingConfig, MintedNFT, MintNFT, NativeRoyaltyPolicy, @@ -2200,7 +2201,30 @@ def test_success_when_license_terms_data_and_royalty_shares_provided_for_mint_nf ): result = ip_asset.register_ip_asset( nft=MintNFT(type="mint", spg_nft_contract=ADDRESS), - license_terms_data=LICENSE_TERMS_DATA, + license_terms_data=[ + LicenseTermsDataInput( + terms=PILFlavor.non_commercial_social_remixing( + override=LicenseTermsOverride( + derivatives_allowed=True, + derivatives_attribution=True, + derivatives_approval=False, + derivatives_reciprocal=True, + royalty_policy=ZERO_ADDRESS, + uri="https://example.com", + ), + ), + licensing_config=LicensingConfig( + is_set=True, + minting_fee=10, + licensing_hook=ZERO_ADDRESS, + hook_data="test", + commercial_rev_share=10, + disabled=False, + expect_minimum_group_reward_share=1, + expect_group_reward_pool=ZERO_ADDRESS, + ), + ), + ], royalty_shares=royalty_shares, ) assert ( @@ -2213,6 +2237,39 @@ def test_success_when_license_terms_data_and_royalty_shares_provided_for_mint_nf mock_build_register_transaction.call_args[0][2] == IPMetadata.from_input().get_validated_data() ) # ip_metadata + assert mock_build_register_transaction.call_args[0][3] == [ + { + "terms": { + "transferable": True, + "commercialAttribution": False, + "commercialRevCeiling": 0, + "commercialRevShare": 0, + "commercialUse": False, + "currency": ZERO_ADDRESS, + "derivativeRevCeiling": 0, + "derivativesAllowed": True, + "derivativesApproval": False, + "derivativesAttribution": True, + "derivativesReciprocal": True, + "expiration": 0, + "defaultMintingFee": 0, + "royaltyPolicy": ZERO_ADDRESS, + "commercializerChecker": ZERO_ADDRESS, + "commercializerCheckerData": ZERO_ADDRESS, + "uri": "https://example.com", + }, + "licensingConfig": { + "isSet": True, + "mintingFee": 10, + "hookData": Web3.to_bytes(text="test"), + "licensingHook": ZERO_ADDRESS, + "commercialRevShare": 10 * 10**6, + "disabled": False, + "expectMinimumGroupRewardShare": 1 * 10**6, + "expectGroupRewardPool": ZERO_ADDRESS, + }, + }, + ] # license_terms_data assert ( mock_build_register_transaction.call_args[0][4] == royalty_shares_obj["royalty_shares"] @@ -2254,7 +2311,33 @@ def test_success_when_license_terms_data_royalty_shares_and_all_optional_paramet allow_duplicates=False, recipient=ADDRESS, ), - license_terms_data=LICENSE_TERMS_DATA, + license_terms_data=[ + LicenseTermsDataInput( + terms=PILFlavor.creative_commons_attribution( + currency=ADDRESS, + override=LicenseTermsOverride( + commercial_attribution=True, + derivatives_allowed=False, + derivatives_attribution=False, + derivatives_approval=False, + derivatives_reciprocal=False, + royalty_policy=ADDRESS, + commercial_rev_share=12, + commercializer_checker="0x", + ), + ), + licensing_config=LicensingConfig( + is_set=False, + minting_fee=10, + licensing_hook=ZERO_ADDRESS, + hook_data="test", + commercial_rev_share=10, + disabled=False, + expect_minimum_group_reward_share=11, + expect_group_reward_pool=ZERO_ADDRESS, + ), + ), + ], royalty_shares=royalty_shares, ip_metadata=IP_METADATA, ) @@ -2265,6 +2348,40 @@ def test_success_when_license_terms_data_royalty_shares_and_all_optional_paramet mock_build_register_transaction.call_args[0][2] == IPMetadata.from_input(IP_METADATA).get_validated_data() ) # ip_metadata + + assert mock_build_register_transaction.call_args[0][3] == [ + { + "terms": { + "transferable": True, + "commercialAttribution": True, + "commercialRevCeiling": 0, + "commercialRevShare": 12 * 10**6, + "commercialUse": True, + "currency": ADDRESS, + "derivativeRevCeiling": 0, + "derivativesAllowed": False, + "derivativesApproval": False, + "derivativesAttribution": False, + "derivativesReciprocal": False, + "expiration": 0, + "defaultMintingFee": 0, + "royaltyPolicy": ADDRESS, + "commercializerChecker": "0x", + "commercializerCheckerData": ZERO_ADDRESS, + "uri": "https://github.com/piplabs/pil-document/blob/998c13e6ee1d04eb817aefd1fe16dfe8be3cd7a2/off-chain-terms/CC-BY.json", + }, + "licensingConfig": { + "isSet": False, + "mintingFee": 10, + "hookData": Web3.to_bytes(text="test"), + "licensingHook": ZERO_ADDRESS, + "commercialRevShare": 10 * 10**6, + "disabled": False, + "expectMinimumGroupRewardShare": 11 * 10**6, + "expectGroupRewardPool": ZERO_ADDRESS, + }, + }, + ] # license_terms_data assert ( mock_build_register_transaction.call_args[0][5] is False ) # allow_duplicates @@ -2306,6 +2423,87 @@ def test_success_when_license_terms_data_provided_for_mint_nft( assert result["token_id"] == 3 assert result["license_terms_ids"] is not None + def test_success_when_license_terms_data_is_commercial_remix_for_mint_nft( + self, + ip_asset: IPAsset, + mock_parse_ip_registered_event, + mock_parse_tx_license_terms_attached_event, + ): + with ( + mock_parse_ip_registered_event(), + mock_parse_tx_license_terms_attached_event(), + patch.object( + ip_asset.license_attachment_workflows_client, + "build_mintAndRegisterIpAndAttachPILTerms_transaction", + return_value={"tx_hash": TX_HASH.hex()}, + ) as mock_build_register_transaction, + ): + ip_asset.register_ip_asset( + nft=MintNFT(type="mint", spg_nft_contract=ADDRESS), + license_terms_data=[ + LicenseTermsDataInput( + terms=PILFlavor.commercial_remix( + default_minting_fee=10, + currency=ADDRESS, + commercial_rev_share=10, + override=LicenseTermsOverride( + commercial_attribution=False, + derivatives_allowed=False, + derivatives_attribution=False, + derivatives_approval=False, + derivatives_reciprocal=False, + royalty_policy=ADDRESS, + commercial_rev_share=12, + ), + ), + licensing_config=LicensingConfig( + is_set=True, + minting_fee=10, + licensing_hook=ZERO_ADDRESS, + hook_data="test", + commercial_rev_share=10, + disabled=False, + expect_minimum_group_reward_share=11, + expect_group_reward_pool=ZERO_ADDRESS, + ), + ), + ], + ) + assert mock_build_register_transaction.call_args[0][3] == [ + { + "terms": { + "transferable": True, + "commercialAttribution": False, + "commercialRevCeiling": 0, + "commercialRevShare": 12 * 10**6, + "commercialUse": True, + "commercializerChecker": ZERO_ADDRESS, + "commercializerCheckerData": ZERO_ADDRESS, + "currency": ADDRESS, + "derivativeRevCeiling": 0, + "derivativesAllowed": False, + "derivativesApproval": False, + "derivativesAttribution": False, + "derivativesReciprocal": False, + "expiration": 0, + "defaultMintingFee": 10, + "royaltyPolicy": ADDRESS, + "uri": "https://github.com/piplabs/pil-document/blob/ad67bb632a310d2557f8abcccd428e4c9c798db1/off-chain-terms/CommercialRemix.json", + }, + "licensingConfig": { + "isSet": True, + "mintingFee": 10, + "hookData": Web3.to_bytes(text="test"), + "licensingHook": ZERO_ADDRESS, + "commercialRevShare": 10 * 10**6, + "disabled": False, + "expectMinimumGroupRewardShare": 11 * 10**6, + "expectGroupRewardPool": ZERO_ADDRESS, + }, + } + ] + # license_terms_data + def test_success_when_license_terms_data_and_all_optional_parameters_provided_for_mint_nft( self, ip_asset: IPAsset, @@ -2381,7 +2579,7 @@ def test_success_when_license_terms_data_provided_for_minted_nft( assert result["token_id"] == 3 assert result["license_terms_ids"] is not None - def test_success_when_license_terms_data_is_generated_by_pil_flavor_for_minted_nft( + def test_success_when_license_terms_data_is_commercial_use_for_minted_nft( self, ip_asset: IPAsset, mock_parse_ip_registered_event, From 4767da78edb4dc19cc3412caf36f2387c69ed23a Mon Sep 17 00:00:00 2001 From: Bonnie Date: Thu, 11 Dec 2025 16:36:49 +0800 Subject: [PATCH 16/21] refactor: improve deprecation messages in License class methods to clarify future usage of register_pil_terms --- src/story_protocol_python_sdk/resources/License.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/story_protocol_python_sdk/resources/License.py b/src/story_protocol_python_sdk/resources/License.py index 9ac973a..f848e87 100644 --- a/src/story_protocol_python_sdk/resources/License.py +++ b/src/story_protocol_python_sdk/resources/License.py @@ -141,7 +141,6 @@ def register_pil_terms( @deprecated( "Use register_pil_terms(**asdict(PILFlavor.non_commercial_social_remixing())) instead. " "In the next major version, register_pil_terms will accept LicenseTermsInput directly, " - "so you can use register_pil_terms(PILFlavor.non_commercial_social_remixing()) without asdict.", ) def register_non_com_social_remixing_pil( self, tx_options: dict | None = None @@ -162,7 +161,6 @@ def register_non_com_social_remixing_pil( @deprecated( "Use register_pil_terms(**asdict(PILFlavor.commercial_use(default_minting_fee, currency, royalty_policy))) instead. " "In the next major version, register_pil_terms will accept LicenseTermsInput directly, " - "so you can use register_pil_terms(PILFlavor.commercial_use(...)) without asdict.", ) def register_commercial_use_pil( self, @@ -195,7 +193,6 @@ def register_commercial_use_pil( @deprecated( "Use register_pil_terms(**asdict(PILFlavor.commercial_remix(default_minting_fee, currency, commercial_rev_share, royalty_policy))) instead. " "In the next major version, register_pil_terms will accept LicenseTermsInput directly, " - "so you can use register_pil_terms(PILFlavor.commercial_remix(...)) without asdict.", ) def register_commercial_remix_pil( self, From d8f65c230d7b458bb2db3a8730897e7ec2f02599 Mon Sep 17 00:00:00 2001 From: Bonnie Date: Thu, 11 Dec 2025 17:43:29 +0800 Subject: [PATCH 17/21] docs: update parameter descriptions in License and PILFlavor classes for improved clarity and consistency --- .../resources/License.py | 2 +- .../types/resource/License.py | 2 +- .../utils/pil_flavor.py | 38 +++++++++---------- tests/integration/test_integration_license.py | 4 +- 4 files changed, 23 insertions(+), 23 deletions(-) diff --git a/src/story_protocol_python_sdk/resources/License.py b/src/story_protocol_python_sdk/resources/License.py index f848e87..0b2e6d3 100644 --- a/src/story_protocol_python_sdk/resources/License.py +++ b/src/story_protocol_python_sdk/resources/License.py @@ -231,7 +231,7 @@ def _register_license_terms_helper( """ Validate the license terms. - :param license_terms LicenseTermsInput: The license terms. + :param license_terms `LicenseTermsInput`: The license terms. :param tx_options dict: [Optional] The transaction options. :return dict: A dictionary with the transaction hash and the license terms ID. """ diff --git a/src/story_protocol_python_sdk/types/resource/License.py b/src/story_protocol_python_sdk/types/resource/License.py index eb2d88a..187b8c1 100644 --- a/src/story_protocol_python_sdk/types/resource/License.py +++ b/src/story_protocol_python_sdk/types/resource/License.py @@ -21,7 +21,7 @@ class LicenseTermsOverride: commercial_attribution: Whether commercial attribution is required. commercializer_checker: The address of the commercializer checker contract. 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. + 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 collected from commercial use. derivatives_allowed: Whether derivatives are allowed. derivatives_attribution: Whether attribution is required for derivatives. diff --git a/src/story_protocol_python_sdk/utils/pil_flavor.py b/src/story_protocol_python_sdk/utils/pil_flavor.py index 483d0ca..5543e9c 100644 --- a/src/story_protocol_python_sdk/utils/pil_flavor.py +++ b/src/story_protocol_python_sdk/utils/pil_flavor.py @@ -134,8 +134,8 @@ def non_commercial_social_remixing( See: https://docs.story.foundation/concepts/programmable-ip-license/pil-flavors#non-commercial-social-remixing - :param override: Optional overrides for the default license terms. - :return: The license terms dictionary. + :param `override` `Optional[LicenseTermsOverride]`: Optional overrides for the default license terms. + :return: `LicenseTermsInput`: The license terms. """ terms = _apply_override(PILFlavor._non_commercial_social_remixing_pil, override) return PILFlavor.validate_license_terms(terms) @@ -152,11 +152,11 @@ def commercial_use( See: https://docs.story.foundation/concepts/programmable-ip-license/pil-flavors#commercial-use - :param default_minting_fee: The fee to be paid when minting a license. - :param currency: The ERC20 token to be used to pay the minting fee. - :param royalty_policy: The type of royalty policy to be used. Default is LAP. - :param override: Optional overrides for the default license terms. - :return: The license terms dictionary. + :param `default_minting_fee` int: The fee to be paid when minting a license. + :param `currency` Address: The ERC20 token to be used to pay the minting fee. + :param `royalty_policy` `Optional[RoyaltyPolicyInput]`: The type of royalty policy to be used.(default: LAP) + :param `override` `Optional[LicenseTermsOverride]`: Optional overrides for the default license terms. + :return: `LicenseTermsInput`: The license terms. """ base = replace( PILFlavor._commercial_use, @@ -180,12 +180,12 @@ def commercial_remix( See: https://docs.story.foundation/concepts/programmable-ip-license/pil-flavors#commercial-remix - :param default_minting_fee: The fee to be paid when minting a license. - :param currency: The ERC20 token to be used to pay the minting fee. - :param commercial_rev_share: Percentage of revenue that must be shared with the licensor. Must be between 0 and 100. - :param royalty_policy: The type of royalty policy to be used. Default is LAP. - :param override: Optional overrides for the default license terms. - :return: The license terms dictionary. + :param `default_minting_fee` int: The fee to be paid when minting a license. + :param `currency` Address: The ERC20 token to be used to pay the minting fee. + :param `commercial_rev_share` int: Percentage of revenue that must be shared with the licensor. Must be between 0 and 100. + :param `royalty_policy` `Optional[RoyaltyPolicyInput]`: The type of royalty policy to be used.(default: LAP) + :param `override` `Optional[LicenseTermsOverride]`: Optional overrides for the default license terms. + :return: `LicenseTermsInput`: The license terms. """ base = replace( PILFlavor._commercial_remix, @@ -208,10 +208,10 @@ def creative_commons_attribution( See: https://docs.story.foundation/concepts/programmable-ip-license/pil-flavors#creative-commons-attribution - :param currency: The ERC20 token to be used to pay the minting fee. - :param royalty_policy: The type of royalty policy to be used. Default is LAP. - :param override: Optional overrides for the default license terms. - :return: The license terms dictionary. + :param `currency` Address: The ERC20 token to be used to pay the minting fee. + :param `royalty_policy` `Optional[RoyaltyPolicyInput]`: The type of royalty policy to be used.(default: LAP) + :param `override` `Optional[LicenseTermsOverride]`: Optional overrides for the default license terms. + :return: `LicenseTermsInput`: The license terms. """ base = replace( PILFlavor._creative_commons_attribution, @@ -226,8 +226,8 @@ def validate_license_terms(params: LicenseTermsInput) -> LicenseTermsInput: """ Validates and normalizes license terms. - :param params: The license terms parameters to validate. - :return: The validated and normalized license terms. + :param params `LicenseTermsInput`: The license terms parameters to validate. + :return: `LicenseTermsInput`: The validated and normalized license terms. :raises PILFlavorError: If validation fails. """ # Normalize royalty_policy to address diff --git a/tests/integration/test_integration_license.py b/tests/integration/test_integration_license.py index 881ffe0..6398adf 100644 --- a/tests/integration/test_integration_license.py +++ b/tests/integration/test_integration_license.py @@ -336,7 +336,7 @@ def test_set_licensing_config( minting_fee=100, is_set=True, licensing_hook=ZERO_ADDRESS, - hook_data=b"", + hook_data="test", commercial_rev_share=100, disabled=False, expect_minimum_group_reward_share=10, @@ -361,7 +361,7 @@ def test_get_licensing_config( is_set=True, minting_fee=100, licensing_hook=ZERO_ADDRESS, - hook_data=b"", + hook_data=b"test", disabled=False, expect_minimum_group_reward_share=10 * 10**6, expect_group_reward_pool=ZERO_ADDRESS, From e33dbf42cda4b288f435905cdfd9f7abddf2c580 Mon Sep 17 00:00:00 2001 From: Bonnie Date: Thu, 11 Dec 2025 18:21:56 +0800 Subject: [PATCH 18/21] test: add unit tests for snake_to_camel and convert_dict_keys_to_camel_case functions --- tests/unit/utils/test_util.py | 45 +++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 tests/unit/utils/test_util.py diff --git a/tests/unit/utils/test_util.py b/tests/unit/utils/test_util.py new file mode 100644 index 0000000..94c491d --- /dev/null +++ b/tests/unit/utils/test_util.py @@ -0,0 +1,45 @@ +from story_protocol_python_sdk.utils.util import ( + convert_dict_keys_to_camel_case, + snake_to_camel, +) + + +class TestSnakeToCamel: + def test_single_word(self): + assert snake_to_camel("hello") == "hello" + + def test_two_words(self): + assert snake_to_camel("hello_world") == "helloWorld" + + def test_multiple_words(self): + assert snake_to_camel("this_is_a_test") == "thisIsATest" + + def test_empty_string(self): + assert snake_to_camel("") == "" + + def test_already_camel_case(self): + assert snake_to_camel("alreadyCamel") == "alreadyCamel" + + +class TestConvertDictKeysToCamelCase: + def test_single_key(self): + result = convert_dict_keys_to_camel_case({"hello_world": 1}) + assert result == {"helloWorld": 1} + + def test_multiple_keys(self): + result = convert_dict_keys_to_camel_case( + { + "first_key": 1, + "second_key": 2, + "third_key": 3, + } + ) + assert result == { + "firstKey": 1, + "secondKey": 2, + "thirdKey": 3, + } + + def test_empty_dict(self): + result = convert_dict_keys_to_camel_case({}) + assert result == {} From 0a63a783efcd5db44e3b6614f04a9328ca785448 Mon Sep 17 00:00:00 2001 From: Bonnie Date: Fri, 12 Dec 2025 10:39:11 +0800 Subject: [PATCH 19/21] feat: enhance IPAsset validation by adding checks for whitelisted royalty policies and currencies, along with corresponding unit tests --- .../resources/IPAsset.py | 28 +++++++--- tests/unit/resources/test_ip_asset.py | 54 +++++++++++++++++++ 2 files changed, 75 insertions(+), 7 deletions(-) diff --git a/src/story_protocol_python_sdk/resources/IPAsset.py b/src/story_protocol_python_sdk/resources/IPAsset.py index 4be3f66..5908074 100644 --- a/src/story_protocol_python_sdk/resources/IPAsset.py +++ b/src/story_protocol_python_sdk/resources/IPAsset.py @@ -2195,12 +2195,26 @@ def _validate_license_terms_data( license_terms.commercial_rev_share ), ) - validated_license_terms_data.append( - { - "terms": convert_dict_keys_to_camel_case(asdict(license_terms)), - "licensingConfig": LicensingConfigData.validate_license_config( - self.module_registry_client, licensing_config_dict - ), - } + if license_terms.royalty_policy != ZERO_ADDRESS: + is_whitelisted = self.royalty_module_client.isWhitelistedRoyaltyPolicy( + license_terms.royalty_policy + ) + if not is_whitelisted: + raise ValueError("The royalty_policy is not whitelisted.") + + if license_terms.currency != ZERO_ADDRESS: + is_whitelisted = self.royalty_module_client.isWhitelistedRoyaltyToken( + license_terms.currency ) + if not is_whitelisted: + raise ValueError("The currency is not whitelisted.") + + validated_license_terms_data.append( + { + "terms": convert_dict_keys_to_camel_case(asdict(license_terms)), + "licensingConfig": LicensingConfigData.validate_license_config( + self.module_registry_client, licensing_config_dict + ), + } + ) return validated_license_terms_data diff --git a/tests/unit/resources/test_ip_asset.py b/tests/unit/resources/test_ip_asset.py index 896f506..0ed3785 100644 --- a/tests/unit/resources/test_ip_asset.py +++ b/tests/unit/resources/test_ip_asset.py @@ -199,6 +199,30 @@ def test_register_with_metadata( assert result["ip_id"] == IP_ID +@pytest.fixture(scope="class") +def mock_is_whitelisted_royalty_policy(ip_asset): + def _mock(is_whitelisted: bool = True): + return patch.object( + ip_asset.royalty_module_client, + "isWhitelistedRoyaltyPolicy", + return_value=is_whitelisted, + ) + + return _mock + + +@pytest.fixture(scope="class") +def mock_is_whitelisted_royalty_token(ip_asset): + def _mock(is_whitelisted: bool = True): + return patch.object( + ip_asset.royalty_module_client, + "isWhitelistedRoyaltyToken", + return_value=is_whitelisted, + ) + + return _mock + + class TestRegisterDerivativeIp: def test_ip_is_already_registered( self, ip_asset, mock_get_ip_id, mock_is_registered @@ -2177,12 +2201,40 @@ def test_success_when_license_terms_data_and_royalty_shares_provided_for_minted_ assert result["royalty_vault"] == royalty_vault assert result["distribute_royalty_tokens_tx_hash"] == TX_HASH.hex() + def test_throw_error_when_royalty_policy_is_not_whitelisted( + self, + ip_asset: IPAsset, + mock_is_whitelisted_royalty_policy, + ): + with mock_is_whitelisted_royalty_policy(False): + with pytest.raises( + ValueError, match="The royalty_policy is not whitelisted." + ): + ip_asset.register_ip_asset( + nft=MintNFT(type="mint", spg_nft_contract=ADDRESS), + license_terms_data=LICENSE_TERMS_DATA, + ) + + def test_throw_error_when_currency_is_not_whitelisted( + self, + ip_asset: IPAsset, + mock_is_whitelisted_royalty_token, + ): + with mock_is_whitelisted_royalty_token(False): + with pytest.raises(ValueError, match="The currency is not whitelisted."): + ip_asset.register_ip_asset( + nft=MintNFT(type="mint", spg_nft_contract=ADDRESS), + license_terms_data=LICENSE_TERMS_DATA, + ) + def test_success_when_license_terms_data_and_royalty_shares_provided_for_mint_nft( self, ip_asset: IPAsset, mock_parse_ip_registered_event, mock_parse_tx_license_terms_attached_event, mock_get_royalty_vault_address_by_ip_id, + mock_is_whitelisted_royalty_policy, + mock_is_whitelisted_royalty_token, ): royalty_shares = [ RoyaltyShareInput(recipient=ACCOUNT_ADDRESS, percentage=50.0), @@ -2193,6 +2245,8 @@ def test_success_when_license_terms_data_and_royalty_shares_provided_for_mint_nf mock_parse_ip_registered_event(), mock_parse_tx_license_terms_attached_event(), mock_get_royalty_vault_address_by_ip_id(royalty_vault), + mock_is_whitelisted_royalty_policy(True), + mock_is_whitelisted_royalty_token(True), patch.object( ip_asset.royalty_token_distribution_workflows_client, "build_mintAndRegisterIpAndAttachPILTermsAndDistributeRoyaltyTokens_transaction", From 9acc876fb9fcd1006283786a0c406ce816ad5ab3 Mon Sep 17 00:00:00 2001 From: Bonnie Date: Fri, 12 Dec 2025 10:43:57 +0800 Subject: [PATCH 20/21] refactor: streamline currency whitelisting check in IPAsset validation logic for improved readability --- .../resources/IPAsset.py | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/src/story_protocol_python_sdk/resources/IPAsset.py b/src/story_protocol_python_sdk/resources/IPAsset.py index 5908074..5c23f4c 100644 --- a/src/story_protocol_python_sdk/resources/IPAsset.py +++ b/src/story_protocol_python_sdk/resources/IPAsset.py @@ -2202,19 +2202,19 @@ def _validate_license_terms_data( if not is_whitelisted: raise ValueError("The royalty_policy is not whitelisted.") - if license_terms.currency != ZERO_ADDRESS: - is_whitelisted = self.royalty_module_client.isWhitelistedRoyaltyToken( - license_terms.currency - ) - if not is_whitelisted: - raise ValueError("The currency is not whitelisted.") - - validated_license_terms_data.append( - { - "terms": convert_dict_keys_to_camel_case(asdict(license_terms)), - "licensingConfig": LicensingConfigData.validate_license_config( - self.module_registry_client, licensing_config_dict - ), - } - ) + if license_terms.currency != ZERO_ADDRESS: + is_whitelisted = self.royalty_module_client.isWhitelistedRoyaltyToken( + license_terms.currency + ) + if not is_whitelisted: + raise ValueError("The currency is not whitelisted.") + + validated_license_terms_data.append( + { + "terms": convert_dict_keys_to_camel_case(asdict(license_terms)), + "licensingConfig": LicensingConfigData.validate_license_config( + self.module_registry_client, licensing_config_dict + ), + } + ) return validated_license_terms_data From 862a81aa26fa928d8b99b07dae4c106ad05f49ad Mon Sep 17 00:00:00 2001 From: Bonnie Date: Fri, 12 Dec 2025 10:59:06 +0800 Subject: [PATCH 21/21] refactor: enhance License class by utilizing replace for commercial revenue share calculation and streamline address validation in DerivativeData class --- src/story_protocol_python_sdk/resources/License.py | 9 ++++++--- src/story_protocol_python_sdk/utils/derivative_data.py | 4 ++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/story_protocol_python_sdk/resources/License.py b/src/story_protocol_python_sdk/resources/License.py index 0b2e6d3..063782f 100644 --- a/src/story_protocol_python_sdk/resources/License.py +++ b/src/story_protocol_python_sdk/resources/License.py @@ -1,4 +1,4 @@ -from dataclasses import asdict +from dataclasses import asdict, replace from ens.ens import Address, HexStr from typing_extensions import deprecated @@ -236,8 +236,11 @@ def _register_license_terms_helper( :return dict: A dictionary with the transaction hash and the license terms ID. """ validated_license_terms = PILFlavor.validate_license_terms(license_terms) - validated_license_terms.commercial_rev_share = get_revenue_share( - validated_license_terms.commercial_rev_share + validated_license_terms = replace( + validated_license_terms, + commercial_rev_share=get_revenue_share( + validated_license_terms.commercial_rev_share + ), ) if validated_license_terms.royalty_policy != ZERO_ADDRESS: is_whitelisted = self.royalty_module_client.isWhitelistedRoyaltyPolicy( diff --git a/src/story_protocol_python_sdk/utils/derivative_data.py b/src/story_protocol_python_sdk/utils/derivative_data.py index 7b2d9df..00ba2f0 100644 --- a/src/story_protocol_python_sdk/utils/derivative_data.py +++ b/src/story_protocol_python_sdk/utils/derivative_data.py @@ -117,8 +117,8 @@ def validate_parent_ip_ids_and_license_terms_ids(self): for parent_ip_id, license_terms_id in zip( self.parent_ip_ids, self.license_terms_ids ): - if not validate_address(parent_ip_id): - raise ValueError("The parent IP ID must be a valid address.") + validate_address(parent_ip_id) + if not self.ip_asset_registry_client.isRegistered(parent_ip_id): raise ValueError(f"The parent IP ID {parent_ip_id} must be registered.") if not self.license_registry_client.hasIpAttachedLicenseTerms(