Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/story_protocol_python_sdk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from .types.resource.IPAsset import (
RegisterPILTermsAndAttachResponse,
RegistrationResponse,
RegistrationWithRoyaltyVaultResponse,
)
from .utils.constants import (
DEFAULT_FUNCTION_SELECTOR,
Expand All @@ -29,6 +30,7 @@
from .utils.derivative_data import DerivativeDataInput
from .utils.ip_metadata import IPMetadataInput
from .utils.licensing_config_data import LicensingConfig
from .utils.royalty_shares import RoyaltyShareInput

__all__ = [
"StoryClient",
Expand All @@ -43,11 +45,13 @@
"DerivativeDataInput",
"IPMetadataInput",
"RegistrationResponse",
"RegistrationWithRoyaltyVaultResponse",
"ClaimRewardsResponse",
"ClaimReward",
"CollectRoyaltiesResponse",
"LicensingConfig",
"RegisterPILTermsAndAttachResponse",
"RoyaltyShareInput",
# Constants
"ZERO_ADDRESS",
"ZERO_HASH",
Expand Down
125 changes: 113 additions & 12 deletions src/story_protocol_python_sdk/resources/IPAsset.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,18 @@
from story_protocol_python_sdk.abi.RegistrationWorkflows.RegistrationWorkflows_client import (
RegistrationWorkflowsClient,
)
from story_protocol_python_sdk.abi.RoyaltyModule.RoyaltyModule_client import (
RoyaltyModuleClient,
)
from story_protocol_python_sdk.abi.RoyaltyTokenDistributionWorkflows.RoyaltyTokenDistributionWorkflows_client import (
RoyaltyTokenDistributionWorkflowsClient,
)
from story_protocol_python_sdk.abi.SPGNFTImpl.SPGNFTImpl_client import SPGNFTImplClient
from story_protocol_python_sdk.types.common import AccessPermission
from story_protocol_python_sdk.types.resource.IPAsset import (
RegisterPILTermsAndAttachResponse,
RegistrationResponse,
RegistrationWithRoyaltyVaultResponse,
)
from story_protocol_python_sdk.utils.constants import (
MAX_ROYALTY_TOKEN,
Expand All @@ -54,6 +61,10 @@
from story_protocol_python_sdk.utils.function_signature import get_function_signature
from story_protocol_python_sdk.utils.ip_metadata import IPMetadata, IPMetadataInput
from story_protocol_python_sdk.utils.license_terms import LicenseTerms
from story_protocol_python_sdk.utils.royalty_shares import (
RoyaltyShare,
RoyaltyShareInput,
)
from story_protocol_python_sdk.utils.sign import Sign
from story_protocol_python_sdk.utils.transaction_utils import build_and_send_transaction
from story_protocol_python_sdk.utils.validation import (
Expand Down Expand Up @@ -89,6 +100,10 @@ def __init__(self, web3: Web3, account, chain_id: int):
self.core_metadata_module_client = CoreMetadataModuleClient(web3)
self.access_controller_client = AccessControllerClient(web3)
self.pi_license_template_client = PILicenseTemplateClient(web3)
self.royalty_token_distribution_workflows_client = (
RoyaltyTokenDistributionWorkflowsClient(web3)
)
self.royalty_module_client = RoyaltyModuleClient(web3)

self.license_terms_util = LicenseTerms(web3)
self.sign_util = Sign(web3, self.chain_id, self.account)
Expand Down Expand Up @@ -457,7 +472,7 @@ def mint_and_register_ip_asset_with_pil_terms(
self.account,
self.license_attachment_workflows_client.build_mintAndRegisterIpAndAttachPILTerms_transaction,
spg_nft_contract,
recipient if recipient else self.account.address,
self._validate_recipient(recipient),
metadata,
license_terms,
allow_duplicates,
Expand Down Expand Up @@ -531,7 +546,7 @@ def mint_and_register_ip(
self.account,
self.registration_workflows_client.build_mintAndRegisterIp_transaction,
spg_nft_contract,
recipient if recipient else self.account.address,
self._validate_recipient(recipient),
metadata,
allow_duplicates,
tx_options=tx_options,
Expand Down Expand Up @@ -817,11 +832,7 @@ def mint_and_register_ip_and_make_derivative(
validate_address(spg_nft_contract),
validated_deriv_data,
IPMetadata.from_input(ip_metadata).get_validated_data(),
(
validate_address(recipient)
if recipient is not None
else self.account.address
),
self._validate_recipient(recipient),
allow_duplicates,
tx_options=tx_options,
)
Expand Down Expand Up @@ -870,11 +881,7 @@ def mint_and_register_ip_and_make_derivative_with_license_tokens(
ZERO_ADDRESS,
max_rts,
IPMetadata.from_input(ip_metadata).get_validated_data(),
(
validate_address(recipient)
if recipient is not None
else self.account.address
),
self._validate_recipient(recipient),
allow_duplicates,
tx_options=tx_options,
)
Expand Down Expand Up @@ -988,6 +995,66 @@ def register_ip_and_make_derivative_with_license_tokens(
f"Failed to register IP and make derivative with license tokens: {str(e)}"
) from e

def mint_and_register_ip_and_make_derivative_and_distribute_royalty_tokens(
self,
spg_nft_contract: Address,
deriv_data: DerivativeDataInput,
royalty_shares: list[RoyaltyShareInput],
ip_metadata: IPMetadataInput | None = None,
recipient: Address | None = None,
allow_duplicates: bool = True,
tx_options: dict | None = None,
) -> RegistrationWithRoyaltyVaultResponse:
"""
Mint an NFT and register the IP, make a derivative, and distribute royalty tokens.

:param spg_nft_contract Address: The address of the SPGNFT collection.
:param deriv_data `DerivativeDataInput`: The derivative data to be used for register derivative.
:param royalty_shares `list[RoyaltyShareInput]`: The royalty shares to be distributed.
:param ip_metadata `IPMetadataInput`: [Optional] The desired metadata for the newly minted NFT and newly registered IP.
:param recipient Address: [Optional] The address to receive the minted NFT. If not provided, the client's own wallet address will be used.
:param allow_duplicates bool: [Optional] Set to true to allow minting an NFT with a duplicate metadata hash. (default: True)
:param tx_options dict: [Optional] Transaction options.
:return `RegistrationWithRoyaltyVaultResponse`: Dictionary with the tx hash, IP ID and token ID, royalty vault.
"""
try:
validated_royalty_shares_obj = RoyaltyShare.get_royalty_shares(
royalty_shares
)
validated_deriv_data = DerivativeData.from_input(
web3=self.web3, input_data=deriv_data
).get_validated_data()

response = build_and_send_transaction(
self.web3,
self.account,
self.royalty_token_distribution_workflows_client.build_mintAndRegisterIpAndMakeDerivativeAndDistributeRoyaltyTokens_transaction,
validate_address(spg_nft_contract),
self._validate_recipient(recipient),
IPMetadata.from_input(ip_metadata).get_validated_data(),
validated_deriv_data,
validated_royalty_shares_obj["royalty_shares"],
allow_duplicates,
tx_options=tx_options,
)

ip_registered = self._parse_tx_ip_registered_event(response["tx_receipt"])
royalty_vault = self.get_royalty_vault_address_by_ip_id(
response["tx_receipt"],
ip_registered["ip_id"],
)

return RegistrationWithRoyaltyVaultResponse(
tx_hash=response["tx_hash"],
ip_id=ip_registered["ip_id"],
token_id=ip_registered["token_id"],
royalty_vault=royalty_vault,
)
except Exception as e:
raise ValueError(
f"Failed to mint, register IP, make derivative and distribute royalty tokens: {str(e)}"
) from e

def register_pil_terms_and_attach(
self,
ip_id: Address,
Expand Down Expand Up @@ -1244,3 +1311,37 @@ def _parse_tx_license_terms_attached_event(self, tx_receipt: dict) -> list[int]:
license_terms_ids.append(license_terms_id)

return license_terms_ids

def get_royalty_vault_address_by_ip_id(
self, tx_receipt: dict, ipId: Address
) -> Address:
"""
Parse the IpRoyaltyVaultDeployed event from a transaction receipt and return the royalty vault address for a given IP ID.

:param tx_receipt dict: The transaction receipt.
:param ipId Address: The IP ID.
:return Address: The royalty vault address.
"""
event_signature = self.web3.keccak(
text="IpRoyaltyVaultDeployed(address,address)"
).hex()
for log in tx_receipt["logs"]:
if log["topics"][0].hex() == event_signature:
event_result = self.royalty_module_client.contract.events.IpRoyaltyVaultDeployed.process_log(
log
)
if event_result["args"]["ipId"] == ipId:
return event_result["args"]["ipRoyaltyVault"]

raise ValueError("RoyaltyVaultDeployed event not found in transaction receipt.")

def _validate_recipient(self, recipient: Address | None) -> Address:
"""
Validate the recipient address.

:param recipient Address: The recipient address to validate.
:return Address: The validated recipient address.
"""
if recipient is None:
return self.account.address
return validate_address(recipient)
17 changes: 15 additions & 2 deletions src/story_protocol_python_sdk/types/resource/IPAsset.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import Optional, TypedDict
from typing import TypedDict

from ens.ens import Address, HexStr

Expand All @@ -15,7 +15,20 @@ class RegistrationResponse(TypedDict):

ip_id: Address
tx_hash: HexStr
token_id: Optional[int]
token_id: int


class RegistrationWithRoyaltyVaultResponse(RegistrationResponse):
"""
Response structure for IP asset registration operations with royalty vault.

Extends `RegistrationResponse` with royalty vault information.

Attributes:
royalty_vault: The royalty vault address of the registered IP asset
"""

royalty_vault: Address


class RegisterPILTermsAndAttachResponse(TypedDict):
Expand Down
71 changes: 71 additions & 0 deletions src/story_protocol_python_sdk/utils/royalty_shares.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
"""Module for handling royalty shares data structure and validation."""

from dataclasses import dataclass
from typing import List

from ens.ens import Address

from story_protocol_python_sdk.utils.validation import validate_address


@dataclass
class RoyaltyShareInput:
"""Input data structure for a single royalty share.

Attributes:
recipient: The address of the recipient.
percentage: The percentage of the total royalty share. Supports up to 6 decimal places precision. For example, a value of 10 represents 10% of max royalty shares, which is 10,000,000.
"""

recipient: Address
percentage: float | int


@dataclass
class RoyaltyShare:
"""Validated royalty share data."""

@classmethod
def get_royalty_shares(cls, royalty_shares: List[RoyaltyShareInput]):
"""
Validate and convert royalty shares.

:param royalty_shares: List of `RoyaltyShareInput`
:return: Dictionary with validated royalty_shares and total_amount
"""
if len(royalty_shares) == 0:
raise ValueError("Royalty shares must be provided.")

actual_total = 0
sum_percentage = 0.0
converted_shares: List[dict] = []

for share_dict in royalty_shares:
recipient = validate_address(share_dict.recipient)
percentage = share_dict.percentage

if percentage < 0:
raise ValueError(
"The percentage of the royalty shares must be greater than or equal to 0."
)

if percentage > 100:
raise ValueError(
"The percentage of the royalty shares must be less than or equal to 100."
)

sum_percentage += percentage
if sum_percentage > 100:
raise ValueError("The sum of the royalty shares cannot exceeds 100.")

value = int(percentage * 10**6)
actual_total += value

converted_shares.append(
{
"recipient": recipient,
"percentage": value,
}
)

return {"royalty_shares": converted_shares, "total_amount": actual_total}
52 changes: 52 additions & 0 deletions tests/integration/test_integration_ip_asset.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from story_protocol_python_sdk.utils.constants import ROYALTY_POLICY_LAP_ADDRESS
from story_protocol_python_sdk.utils.derivative_data import DerivativeDataInput
from story_protocol_python_sdk.utils.ip_metadata import IPMetadataInput
from story_protocol_python_sdk.utils.royalty_shares import RoyaltyShareInput
from tests.integration.config.test_config import account_2
from tests.integration.config.utils import approve

Expand Down Expand Up @@ -988,3 +989,54 @@ def test_successful_registration(
assert response is not None
assert isinstance(response["tx_hash"], str)
assert len(response["license_terms_ids"]) == 2


class TestMintAndRegisterIpAndMakeDerivativeAndDistributeRoyaltyTokens:
def test_mint_register_ip_make_derivative_distribute_royalty_tokens_default_value(
self, story_client: StoryClient, nft_collection, parent_ip_and_license_terms
):
response = story_client.IPAsset.mint_and_register_ip_and_make_derivative_and_distribute_royalty_tokens(
spg_nft_contract=nft_collection,
deriv_data=DerivativeDataInput(
parent_ip_ids=[parent_ip_and_license_terms["parent_ip_id"]],
license_terms_ids=[parent_ip_and_license_terms["license_terms_id"]],
),
royalty_shares=[
RoyaltyShareInput(recipient=account.address, percentage=50.000032222),
RoyaltyShareInput(recipient=account_2.address, percentage=30.000032222),
],
)
assert isinstance(response["tx_hash"], str)
assert isinstance(response["ip_id"], str)
assert isinstance(response["token_id"], int)
assert isinstance(response["royalty_vault"], str)

def test_mint_register_ip_make_derivative_distribute_royalty_tokens_with_custom_values(
self, story_client: StoryClient, nft_collection, parent_ip_and_license_terms
):
response = story_client.IPAsset.mint_and_register_ip_and_make_derivative_and_distribute_royalty_tokens(
spg_nft_contract=nft_collection,
deriv_data=DerivativeDataInput(
parent_ip_ids=[parent_ip_and_license_terms["parent_ip_id"]],
license_terms_ids=[parent_ip_and_license_terms["license_terms_id"]],
max_minting_fee=10000,
max_rts=10,
max_revenue_share=100,
),
royalty_shares=[
RoyaltyShareInput(recipient=account.address, percentage=60),
RoyaltyShareInput(recipient=account_2.address, percentage=40),
],
ip_metadata=IPMetadataInput(
ip_metadata_uri="https://example.com/ip-metadata",
ip_metadata_hash=web3.keccak(text="ip_metadata_hash"),
nft_metadata_uri="https://example.com/nft-metadata",
nft_metadata_hash=web3.keccak(text="nft_metadata_hash"),
),
recipient=account_2.address,
allow_duplicates=False,
)
assert isinstance(response["tx_hash"], str)
assert isinstance(response["ip_id"], str)
assert isinstance(response["token_id"], int)
assert isinstance(response["royalty_vault"], str)
Loading
Loading