diff --git a/src/story_protocol_python_sdk/__init__.py b/src/story_protocol_python_sdk/__init__.py index 728fe626..dc573cc6 100644 --- a/src/story_protocol_python_sdk/__init__.py +++ b/src/story_protocol_python_sdk/__init__.py @@ -32,6 +32,7 @@ MAX_ROYALTY_TOKEN, ROYALTY_POLICY_LAP_ADDRESS, ROYALTY_POLICY_LRP_ADDRESS, + WIP_TOKEN_ADDRESS, ZERO_ADDRESS, ZERO_FUNC, ZERO_HASH, @@ -76,4 +77,5 @@ "ZERO_FUNC", "DEFAULT_FUNCTION_SELECTOR", "MAX_ROYALTY_TOKEN", + "WIP_TOKEN_ADDRESS", ] diff --git a/src/story_protocol_python_sdk/abi/SPGNFTImpl/SPGNFTImpl_client.py b/src/story_protocol_python_sdk/abi/SPGNFTImpl/SPGNFTImpl_client.py index e29db284..4490654e 100644 --- a/src/story_protocol_python_sdk/abi/SPGNFTImpl/SPGNFTImpl_client.py +++ b/src/story_protocol_python_sdk/abi/SPGNFTImpl/SPGNFTImpl_client.py @@ -19,6 +19,3 @@ def mintFee(self): def mintFeeToken(self): return self.contract.functions.mintFeeToken().call() - - def publicMinting(self): - return self.contract.functions.publicMinting().call() diff --git a/src/story_protocol_python_sdk/abi/WrappedIP/WrappedIP_client.py b/src/story_protocol_python_sdk/abi/WrappedIP/WrappedIP_client.py new file mode 100644 index 00000000..3123af61 --- /dev/null +++ b/src/story_protocol_python_sdk/abi/WrappedIP/WrappedIP_client.py @@ -0,0 +1,39 @@ +import json +import os + +from web3 import Web3 + + +class WrappedIPClient: + def __init__(self, web3: Web3): + self.web3 = web3 + # Assuming config.json is located at the root of the project + config_path = os.path.abspath( + os.path.join( + os.path.dirname(__file__), "..", "..", "scripts", "config.json" + ) + ) + with open(config_path, "r") as config_file: + config = json.load(config_file) + contract_address = None + for contract in config["contracts"]: + if contract["contract_name"] == "WrappedIP": + contract_address = contract["contract_address"] + break + if not contract_address: + raise ValueError("Contract address for WrappedIP not found in config.json") + abi_path = os.path.join( + os.path.dirname(__file__), "..", "..", "abi", "jsons", "WrappedIP.json" + ) + with open(abi_path, "r") as abi_file: + abi = json.load(abi_file) + self.contract = self.web3.eth.contract(address=contract_address, abi=abi) + + def withdraw(self, value): + return self.contract.functions.withdraw(value).transact() + + def build_withdraw_transaction(self, value, tx_params): + return self.contract.functions.withdraw(value).build_transaction(tx_params) + + def balanceOf(self, owner): + return self.contract.functions.balanceOf(owner).call() diff --git a/src/story_protocol_python_sdk/abi/jsons/WrappedIP.json b/src/story_protocol_python_sdk/abi/jsons/WrappedIP.json new file mode 100644 index 00000000..3468814a --- /dev/null +++ b/src/story_protocol_python_sdk/abi/jsons/WrappedIP.json @@ -0,0 +1,253 @@ +[ + { "type": "error", "inputs": [], "name": "AllowanceOverflow" }, + { "type": "error", "inputs": [], "name": "AllowanceUnderflow" }, + { + "type": "error", + "inputs": [ + { "name": "receiver", "internalType": "address", "type": "address" } + ], + "name": "ERC20InvalidReceiver" + }, + { + "type": "error", + "inputs": [ + { "name": "spender", "internalType": "address", "type": "address" } + ], + "name": "ERC20InvalidSpender" + }, + { "type": "error", "inputs": [], "name": "IPTransferFailed" }, + { "type": "error", "inputs": [], "name": "InsufficientAllowance" }, + { "type": "error", "inputs": [], "name": "InsufficientBalance" }, + { "type": "error", "inputs": [], "name": "InvalidPermit" }, + { + "type": "error", + "inputs": [], + "name": "Permit2AllowanceIsFixedAtInfinity" + }, + { "type": "error", "inputs": [], "name": "PermitExpired" }, + { "type": "error", "inputs": [], "name": "TotalSupplyOverflow" }, + { + "type": "event", + "anonymous": false, + "inputs": [ + { + "name": "owner", + "internalType": "address", + "type": "address", + "indexed": true + }, + { + "name": "spender", + "internalType": "address", + "type": "address", + "indexed": true + }, + { + "name": "amount", + "internalType": "uint256", + "type": "uint256", + "indexed": false + } + ], + "name": "Approval" + }, + { + "type": "event", + "anonymous": false, + "inputs": [ + { + "name": "from", + "internalType": "address", + "type": "address", + "indexed": true + }, + { + "name": "amount", + "internalType": "uint256", + "type": "uint256", + "indexed": false + } + ], + "name": "Deposit" + }, + { + "type": "event", + "anonymous": false, + "inputs": [ + { + "name": "from", + "internalType": "address", + "type": "address", + "indexed": true + }, + { + "name": "to", + "internalType": "address", + "type": "address", + "indexed": true + }, + { + "name": "amount", + "internalType": "uint256", + "type": "uint256", + "indexed": false + } + ], + "name": "Transfer" + }, + { + "type": "event", + "anonymous": false, + "inputs": [ + { + "name": "to", + "internalType": "address", + "type": "address", + "indexed": true + }, + { + "name": "amount", + "internalType": "uint256", + "type": "uint256", + "indexed": false + } + ], + "name": "Withdrawal" + }, + { + "type": "function", + "inputs": [], + "name": "DOMAIN_SEPARATOR", + "outputs": [ + { "name": "result", "internalType": "bytes32", "type": "bytes32" } + ], + "stateMutability": "view" + }, + { + "type": "function", + "inputs": [ + { "name": "owner", "internalType": "address", "type": "address" }, + { "name": "spender", "internalType": "address", "type": "address" } + ], + "name": "allowance", + "outputs": [ + { "name": "result", "internalType": "uint256", "type": "uint256" } + ], + "stateMutability": "view" + }, + { + "type": "function", + "inputs": [ + { "name": "spender", "internalType": "address", "type": "address" }, + { "name": "amount", "internalType": "uint256", "type": "uint256" } + ], + "name": "approve", + "outputs": [{ "name": "", "internalType": "bool", "type": "bool" }], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "inputs": [ + { "name": "owner", "internalType": "address", "type": "address" } + ], + "name": "balanceOf", + "outputs": [ + { "name": "result", "internalType": "uint256", "type": "uint256" } + ], + "stateMutability": "view" + }, + { + "type": "function", + "inputs": [], + "name": "decimals", + "outputs": [{ "name": "", "internalType": "uint8", "type": "uint8" }], + "stateMutability": "view" + }, + { + "type": "function", + "inputs": [], + "name": "deposit", + "outputs": [], + "stateMutability": "payable" + }, + { + "type": "function", + "inputs": [], + "name": "name", + "outputs": [{ "name": "", "internalType": "string", "type": "string" }], + "stateMutability": "pure" + }, + { + "type": "function", + "inputs": [ + { "name": "owner", "internalType": "address", "type": "address" } + ], + "name": "nonces", + "outputs": [ + { "name": "result", "internalType": "uint256", "type": "uint256" } + ], + "stateMutability": "view" + }, + { + "type": "function", + "inputs": [ + { "name": "owner", "internalType": "address", "type": "address" }, + { "name": "spender", "internalType": "address", "type": "address" }, + { "name": "value", "internalType": "uint256", "type": "uint256" }, + { "name": "deadline", "internalType": "uint256", "type": "uint256" }, + { "name": "v", "internalType": "uint8", "type": "uint8" }, + { "name": "r", "internalType": "bytes32", "type": "bytes32" }, + { "name": "s", "internalType": "bytes32", "type": "bytes32" } + ], + "name": "permit", + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "inputs": [], + "name": "symbol", + "outputs": [{ "name": "", "internalType": "string", "type": "string" }], + "stateMutability": "pure" + }, + { + "type": "function", + "inputs": [], + "name": "totalSupply", + "outputs": [ + { "name": "result", "internalType": "uint256", "type": "uint256" } + ], + "stateMutability": "view" + }, + { + "type": "function", + "inputs": [ + { "name": "to", "internalType": "address", "type": "address" }, + { "name": "amount", "internalType": "uint256", "type": "uint256" } + ], + "name": "transfer", + "outputs": [{ "name": "", "internalType": "bool", "type": "bool" }], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "inputs": [ + { "name": "from", "internalType": "address", "type": "address" }, + { "name": "to", "internalType": "address", "type": "address" }, + { "name": "amount", "internalType": "uint256", "type": "uint256" } + ], + "name": "transferFrom", + "outputs": [{ "name": "", "internalType": "bool", "type": "bool" }], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "inputs": [ + { "name": "value", "internalType": "uint256", "type": "uint256" } + ], + "name": "withdraw", + "outputs": [], + "stateMutability": "nonpayable" + }, + { "type": "receive", "stateMutability": "payable" } +] diff --git a/src/story_protocol_python_sdk/resources/IPAsset.py b/src/story_protocol_python_sdk/resources/IPAsset.py index 855bce4d..c307c6bd 100644 --- a/src/story_protocol_python_sdk/resources/IPAsset.py +++ b/src/story_protocol_python_sdk/resources/IPAsset.py @@ -1703,7 +1703,7 @@ def get_royalty_vault_address_by_ip_id( :param ipId Address: The IP ID. :return Address: The royalty vault address. """ - event_signature = self.web3.keccak( + event_signature = Web3.keccak( text="IpRoyaltyVaultDeployed(address,address)" ).hex() for log in tx_receipt["logs"]: @@ -1714,8 +1714,6 @@ def get_royalty_vault_address_by_ip_id( 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. diff --git a/src/story_protocol_python_sdk/resources/Royalty.py b/src/story_protocol_python_sdk/resources/Royalty.py index 8e865063..777febbf 100644 --- a/src/story_protocol_python_sdk/resources/Royalty.py +++ b/src/story_protocol_python_sdk/resources/Royalty.py @@ -24,7 +24,13 @@ from story_protocol_python_sdk.abi.RoyaltyWorkflows.RoyaltyWorkflows_client import ( RoyaltyWorkflowsClient, ) +from story_protocol_python_sdk.abi.WrappedIP.WrappedIP_client import WrappedIPClient +from story_protocol_python_sdk.utils.constants import WIP_TOKEN_ADDRESS from story_protocol_python_sdk.utils.transaction_utils import build_and_send_transaction +from story_protocol_python_sdk.utils.validation import ( + validate_address, + validate_addresses, +) class Royalty: @@ -49,6 +55,7 @@ def __init__(self, web3: Web3, account, chain_id: int): self.ip_account_impl_client = IPAccountImplClient(web3) self.mock_erc20_client = MockERC20Client(web3) self.royalty_policy_lrp_client = RoyaltyPolicyLRPClient(web3) + self.wrapped_ip_client = WrappedIPClient(web3) def get_royalty_vault_address(self, ip_id: str) -> str: """ @@ -151,40 +158,29 @@ def claim_all_revenue( tx_options: dict | None = None, ) -> dict: """ - Claims all revenue from the child IPs of an ancestor IP, then optionally transfers and unwraps tokens. - - :param ancestor_ip_id str: The IP ID of the ancestor. - :param claimer str: The address of the claimer. - :param child_ip_ids list: List of child IP IDs. - :param royalty_policies list: List of royalty policy addresses. - :param currency_tokens list: List of currency token addresses. - :param claim_options dict: [Optional] Options for auto-transfer and unwrapping. - :param tx_options dict: [Optional] The transaction options. - :return dict: A dictionary with transaction details and claimed tokens. + Claims all revenue from the child IPs of an ancestor IP, then transfer all claimed tokens to the wallet if the wallet owns the IP or is the claimer. If claimed token is `WIP(Wrapped IP)`, it will also be converted back to native tokens. + + Even if there are no child IPs, you must still populate `currency_tokens` with the token addresses you wish to claim. This is required for the claim operation to know which token balances to process. + :param ancestor_ip_id str: The IP ID of the ancestor. + :param claimer str: The address of the claimer. + :param child_ip_ids list: List of child IP IDs. + :param royalty_policies list: List of royalty policy addresses. + :param currency_tokens list: List of currency token addresses. + :param claim_options dict: [Optional] Options for `auto_transfer_all_claimed_tokens_from_ip` and `auto_unwrap_ip_tokens`. Default values are True. + :param tx_options dict: [Optional] The transaction options. + :return dict: A dictionary with transaction details and claimed tokens. """ try: - # Validate addresses - if not self.web3.is_address(ancestor_ip_id): - raise ValueError("Invalid ancestor IP ID address") - if not self.web3.is_address(claimer): - raise ValueError("Invalid claimer address") - if not all(self.web3.is_address(addr) for addr in child_ip_ids): - raise ValueError("Invalid child IP ID address") - if not all(self.web3.is_address(addr) for addr in royalty_policies): - raise ValueError("Invalid royalty policy address") - if not all(self.web3.is_address(addr) for addr in currency_tokens): - raise ValueError("Invalid currency token address") - - # Claim revenue + response = build_and_send_transaction( self.web3, self.account, self.royalty_workflows_client.build_claimAllRevenue_transaction, - ancestor_ip_id, - claimer, - child_ip_ids, - royalty_policies, - currency_tokens, + validate_address(ancestor_ip_id), + validate_address(claimer), + validate_addresses(child_ip_ids), + validate_addresses(royalty_policies), + validate_addresses(currency_tokens), tx_options=tx_options, ) @@ -192,12 +188,6 @@ def claim_all_revenue( # Determine if the claimer is an IP owned by the wallet. owns_claimer, is_claimer_ip, ip_account = self._get_claimer_info(claimer) - - # If wallet does not own the claimer then we cannot auto claim. - # If owns_claimer is false, it means the claimer is neither an IP owned by the wallet nor the wallet address itself. - if not owns_claimer: - return {"receipt": response["tx_receipt"], "tx_hashes": tx_hashes} - claimed_tokens = self._parse_tx_revenue_token_claimed_event( response["tx_receipt"] ) @@ -207,15 +197,22 @@ def claim_all_revenue( if claim_options else True ) - # auto_unwrap = claim_options['auto_unwrap_ip_tokens'] + auto_unwrap = ( + claim_options.get("auto_unwrap_ip_tokens", True) + if claim_options + else True + ) - # transfer claimed tokens from IP to wallet if wallet owns IP if auto_transfer and is_claimer_ip and owns_claimer: hashes = self._transfer_claimed_tokens_from_ip_to_wallet( - ancestor_ip_id, ip_account, claimed_tokens + ip_account, claimed_tokens ) tx_hashes.extend(hashes) + if auto_unwrap and owns_claimer: + hashes = self._unwrap_claimed_tokens_from_ip_to_wallet(claimed_tokens) + tx_hashes.extend(hashes) + return { "receipt": response["tx_receipt"], "claimed_tokens": claimed_tokens, @@ -294,7 +291,6 @@ def _get_claimer_info(self, claimer): """ is_claimer_ip = self.ip_asset_registry_client.isRegistered(claimer) owns_claimer = claimer == self.account.address - ip_account = None if is_claimer_ip: ip_account = IPAccountImplClient(self.web3, contract_address=claimer) @@ -304,7 +300,7 @@ def _get_claimer_info(self, claimer): return owns_claimer, is_claimer_ip, ip_account def _transfer_claimed_tokens_from_ip_to_wallet( - self, ancestor_ip_id: str, ip_account, claimed_tokens: list + self, ip_account, claimed_tokens: list ) -> list: """ Transfer claimed tokens from an IP account to the wallet. @@ -314,7 +310,7 @@ def _transfer_claimed_tokens_from_ip_to_wallet( :param claimed_tokens list: List of claimed tokens, each containing token address and amount :return list: List of transaction hashes """ - tx_hashes = [] + calls = [] for claimed_token in claimed_tokens: token = claimed_token["token"] @@ -327,20 +323,23 @@ def _transfer_claimed_tokens_from_ip_to_wallet( transfer_data = self.mock_erc20_client.contract.encode_abi( abi_element_identifier="transfer", args=[self.account.address, amount] ) - - # Execute transfer through IP account - use build_and_send_transaction to properly sign with account - response = build_and_send_transaction( - self.web3, - self.account, - ip_account.build_execute_transaction, - self.web3.to_checksum_address(token), - 0, - transfer_data, - 0, - ) - tx_hashes.append(response["tx_hash"]) - - return tx_hashes + calls.append( + { + "target": token, + "value": 0, + "data": transfer_data, + } + ) + if len(calls) > 0: + response = build_and_send_transaction( + self.web3, + self.account, + ip_account.build_executeBatch_transaction, + calls, + 0, + ) + return [response["tx_hash"]] + return [] def _parse_tx_revenue_token_claimed_event(self, tx_receipt: dict) -> list: """ @@ -349,37 +348,52 @@ def _parse_tx_revenue_token_claimed_event(self, tx_receipt: dict) -> list: :param tx_receipt dict: The transaction receipt. :return list: List of claimed tokens with claimer address, token address and amount. """ - event_signature = self.web3.keccak( + event_signature = Web3.keccak( text="RevenueTokenClaimed(address,address,uint256)" ).hex() claimed_tokens = [] - for log in tx_receipt.get("logs", []): if log["topics"][0].hex() == event_signature: - data = log["data"] - - # Convert HexBytes to hex string without '0x' prefix - data_hex = ( - data.hex() - if hasattr(data, "hex") - else ( - data[2:] - if isinstance(data, str) and data.startswith("0x") - else data - ) - ) - - # Each parameter is 32 bytes (64 hex chars) - claimer = ( - "0x" + data_hex[24:64] - ) # First 20 bytes of the first parameter - token = ( - "0x" + data_hex[88:128] - ) # First 20 bytes of the second parameter - amount = int(data_hex[128:], 16) # Third parameter - - claimed_tokens.append( - {"claimer": claimer, "token": token, "amount": amount} - ) + event_result = self.ip_royalty_vault_client.contract.events.RevenueTokenClaimed.process_log( + log + )[ + "args" + ] + claimed_tokens.append(event_result) return claimed_tokens + + def _unwrap_claimed_tokens_from_ip_to_wallet(self, claimed_tokens: list) -> list: + """ + Unwrap claimed tokens from an IP account to the wallet. + + :param claimed_tokens list: List of claimed tokens, each containing token address and amount + :return list: List of transaction hashes + """ + tx_hashes: list[str] = [] + + # Filter for WIP tokens + wip_tokens = [ + token for token in claimed_tokens if token["token"] == WIP_TOKEN_ADDRESS + ] + + if len(wip_tokens) > 1: + raise ValueError("Multiple WIP tokens found in the claimed tokens.") + + if not wip_tokens: + return tx_hashes + + wip_token = wip_tokens[0] + if wip_token["amount"] <= 0: + return tx_hashes + + # Withdraw WIP tokens + response = build_and_send_transaction( + self.web3, + self.account, + self.wrapped_ip_client.build_withdraw_transaction, + wip_token["amount"], + ) + + tx_hashes.append(response["tx_hash"]) + return tx_hashes diff --git a/src/story_protocol_python_sdk/scripts/config.json b/src/story_protocol_python_sdk/scripts/config.json index 235b2111..91df2b58 100644 --- a/src/story_protocol_python_sdk/scripts/config.json +++ b/src/story_protocol_python_sdk/scripts/config.json @@ -251,6 +251,11 @@ "contract_name": "Multicall3", "contract_address": "0xcA11bde05977b3631167028862bE2a173976CA11", "functions": ["aggregate3Value"] + }, + { + "contract_name": "WrappedIP", + "contract_address": "0x1514000000000000000000000000000000000000", + "functions": ["withdraw", "balanceOf" ] } ] } diff --git a/src/story_protocol_python_sdk/utils/constants.py b/src/story_protocol_python_sdk/utils/constants.py index a0f31171..835244c8 100644 --- a/src/story_protocol_python_sdk/utils/constants.py +++ b/src/story_protocol_python_sdk/utils/constants.py @@ -5,3 +5,4 @@ MAX_ROYALTY_TOKEN = 100000000 ROYALTY_POLICY_LAP_ADDRESS = "0xBe54FB168b3c982b7AaE60dB6CF75Bd8447b390E" ROYALTY_POLICY_LRP_ADDRESS = "0x9156E603C949481883B1D3355C6F1132D191FC41" +WIP_TOKEN_ADDRESS = "0x1514000000000000000000000000000000000000" diff --git a/src/story_protocol_python_sdk/utils/validation.py b/src/story_protocol_python_sdk/utils/validation.py index 01f7d9b1..dab684f2 100644 --- a/src/story_protocol_python_sdk/utils/validation.py +++ b/src/story_protocol_python_sdk/utils/validation.py @@ -16,6 +16,19 @@ def validate_address(address: str) -> str: return address +def validate_addresses(addresses: list[str]) -> list[str]: + """ + Validates if the provided list of strings are valid Ethereum addresses. + + :param addresses list[str]: The list of addresses to validate. + :return list[str]: The validated list of addresses. + :raises ValueError: If any address is not valid. + """ + if not all(Web3.is_address(address) for address in addresses): + raise ValueError(f"Invalid addresses: {addresses}.") + return addresses + + def get_revenue_share( revShare: int, type: RevShareType = RevShareType.COMMERCIAL_REVENUE_SHARE, diff --git a/tests/integration/test_integration_royalty.py b/tests/integration/test_integration_royalty.py index 7d9c1ca5..9bd06cdb 100644 --- a/tests/integration/test_integration_royalty.py +++ b/tests/integration/test_integration_royalty.py @@ -1,10 +1,8 @@ -# tests/integration/test_integration_royalty.py - -import copy - import pytest from story_protocol_python_sdk.story_client import StoryClient +from story_protocol_python_sdk.utils.constants import WIP_TOKEN_ADDRESS +from story_protocol_python_sdk.utils.derivative_data import DerivativeDataInput from .setup_for_integration import ( PIL_LICENSE_TEMPLATE, @@ -152,13 +150,24 @@ def test_pay_royalty_invalid_amount( class TestClaimAllRevenue: - @pytest.fixture(scope="module") - def setup_claim_all_revenue(self, story_client: StoryClient): + def test_claim_all_revenue(self, story_client: StoryClient): + """Test claiming all revenue with WIP tokens and automatic unwrapping + + Test flow: + 1. Create NFT collection + 2. Set up derivative chain: A->B->C->D + - Each derivative pays 100 WIP tokens as minting fee + - 10% LAP royalty share is configured + 3. IP A earns 120 WIP tokens total (100 + 10 + 10) + 4. Claim revenue with default options (auto_transfer=True, auto_unwrap=True) + 5. Verify WIP tokens are automatically unwrapped to native tokens + + Expected: Wallet balance increases by 120 native tokens (WIP unwrapped) + """ # Create NFT collection collection_response = story_client.NFTClient.create_nft_collection( name="free-collection", - symbol="FREE", - max_supply=100, + symbol="test-collection", is_public_minting=True, mint_open=True, contract_uri="test-uri", @@ -166,151 +175,41 @@ def setup_claim_all_revenue(self, story_client: StoryClient): ) spg_nft_contract = collection_response["nft_contract"] - # Define license terms data template - license_terms_template = [ - { - "terms": { - "transferable": True, - "royalty_policy": ROYALTY_POLICY, - "default_minting_fee": 100, - "expiration": 0, - "commercial_use": True, - "commercial_attribution": False, - "commercializer_checker": ZERO_ADDRESS, - "commercializer_checker_data": ZERO_ADDRESS, - "commercial_rev_share": 10, - "commercial_rev_ceiling": 0, - "derivatives_allowed": True, - "derivatives_attribution": True, - "derivatives_approval": False, - "derivatives_reciprocal": True, - "derivative_rev_ceiling": 0, - "currency": MockERC20, - "uri": "", - }, - "licensing_config": { - "is_set": True, - "minting_fee": 100, - "hook_data": ZERO_ADDRESS, - "licensing_hook": ZERO_ADDRESS, - "commercial_rev_share": 0, - "disabled": False, - "expect_minimum_group_reward_share": 0, - "expect_group_reward_pool": ZERO_ADDRESS, - }, - } - ] - - # Create unique metadata for each IP - metadata_a = { - "ip_metadata_uri": "test-uri-a", - "ip_metadata_hash": web3.to_hex(web3.keccak(text="test-metadata-hash-a")), - "nft_metadata_uri": "test-nft-uri-a", - "nft_metadata_hash": web3.to_hex( - web3.keccak(text="test-nft-metadata-hash-a") - ), - } - - metadata_b = { - "ip_metadata_uri": "test-uri-b", - "ip_metadata_hash": web3.to_hex(web3.keccak(text="test-metadata-hash-b")), - "nft_metadata_uri": "test-nft-uri-b", - "nft_metadata_hash": web3.to_hex( - web3.keccak(text="test-nft-metadata-hash-b") - ), - } - - metadata_c = { - "ip_metadata_uri": "test-uri-c", - "ip_metadata_hash": web3.to_hex(web3.keccak(text="test-metadata-hash-c")), - "nft_metadata_uri": "test-nft-uri-c", - "nft_metadata_hash": web3.to_hex( - web3.keccak(text="test-nft-metadata-hash-c") - ), - } - - metadata_d = { - "ip_metadata_uri": "test-uri-d", - "ip_metadata_hash": web3.to_hex(web3.keccak(text="test-metadata-hash-d")), - "nft_metadata_uri": "test-nft-uri-d", - "nft_metadata_hash": web3.to_hex( - web3.keccak(text="test-nft-metadata-hash-d") - ), - } - - # Register IP A with PIL terms - ip_a_response = story_client.IPAsset.mint_and_register_ip_asset_with_pil_terms( - spg_nft_contract=spg_nft_contract, - terms=copy.deepcopy(license_terms_template), - ip_metadata=metadata_a, - ) - ip_a = ip_a_response["ip_id"] - license_terms_id = ip_a_response["license_terms_ids"][0] - - # Register IP B as derivative of A - ip_b_response = story_client.IPAsset.mint_and_register_ip( - spg_nft_contract=spg_nft_contract, ip_metadata=metadata_b - ) - ip_b = ip_b_response["ip_id"] - story_client.IPAsset.register_derivative( - child_ip_id=ip_b, parent_ip_ids=[ip_a], license_terms_ids=[license_terms_id] - ) - - # Register IP C as derivative of B - ip_c_response = story_client.IPAsset.mint_and_register_ip( - spg_nft_contract=spg_nft_contract, ip_metadata=metadata_c - ) - ip_c = ip_c_response["ip_id"] - story_client.IPAsset.register_derivative( - child_ip_id=ip_c, parent_ip_ids=[ip_b], license_terms_ids=[license_terms_id] - ) - - # Register IP D as derivative of C - ip_d_response = story_client.IPAsset.mint_and_register_ip( - spg_nft_contract=spg_nft_contract, ip_metadata=metadata_d - ) - ip_d = ip_d_response["ip_id"] - story_client.IPAsset.register_derivative( - child_ip_id=ip_d, parent_ip_ids=[ip_c], license_terms_ids=[license_terms_id] - ) + def wrapper_derivative_with_wip(parent_ip_id, license_terms_id): + """ + Helper function to create a derivative IP using WIP tokens for minting fee. + + Steps: + 1. Predict the minting fee for the license + 2. Deposit native tokens to get WIP tokens + 3. Approve the SPG contract to spend WIP tokens + 4. Mint and register IP as derivative + """ + # Predict how much WIP tokens are needed for minting fee + minting_fee = story_client.License.predict_minting_license_fee( + licensor_ip_id=parent_ip_id, + license_terms_id=license_terms_id, + amount=1, + ) + amount = minting_fee["amount"] - return {"ip_a": ip_a, "ip_b": ip_b, "ip_c": ip_c, "ip_d": ip_d} + # Deposit native tokens to get WIP tokens + story_client.WIP.deposit(amount=amount) - def test_claim_all_revenue( - self, setup_claim_all_revenue, story_client: StoryClient - ): - response = story_client.Royalty.claim_all_revenue( - ancestor_ip_id=setup_claim_all_revenue["ip_a"], - claimer=setup_claim_all_revenue["ip_a"], - child_ip_ids=[ - setup_claim_all_revenue["ip_b"], - setup_claim_all_revenue["ip_c"], - ], - royalty_policies=[ROYALTY_POLICY, ROYALTY_POLICY], - currency_tokens=[MockERC20, MockERC20], - ) + # Approve SPG contract to spend WIP tokens + story_client.WIP.approve(spender=spg_nft_contract, amount=amount) - assert response is not None - assert "tx_hashes" in response - assert isinstance(response["tx_hashes"], list) - assert len(response["tx_hashes"]) > 0 - assert response["claimed_tokens"][0]["amount"] == 120 - - @pytest.fixture(scope="module") - def setup_claim_all_revenue_claim_options(self, story_client: StoryClient): - # Create NFT collection - collection_response = story_client.NFTClient.create_nft_collection( - name="free-collection", - symbol="FREE", - max_supply=100, - is_public_minting=True, - mint_open=True, - contract_uri="test-uri", - mint_fee_recipient=ZERO_ADDRESS, - ) - spg_nft_contract = collection_response["nft_contract"] + # Mint and register the derivative IP + response = story_client.IPAsset.mint_and_register_ip_and_make_derivative( + spg_nft_contract=spg_nft_contract, + deriv_data=DerivativeDataInput( + parent_ip_ids=[parent_ip_id], + license_terms_ids=[license_terms_id], + ), + ) + return response["ip_id"] - # Define license terms data template + # Define license terms: 100 WIP minting fee + 10% royalty share license_terms_template = [ { "terms": { @@ -322,19 +221,19 @@ def setup_claim_all_revenue_claim_options(self, story_client: StoryClient): "commercial_attribution": False, "commercializer_checker": ZERO_ADDRESS, "commercializer_checker_data": ZERO_ADDRESS, - "commercial_rev_share": 10, + "commercial_rev_share": 10, # 10% royalty share "commercial_rev_ceiling": 0, "derivatives_allowed": True, "derivatives_attribution": True, "derivatives_approval": False, "derivatives_reciprocal": True, "derivative_rev_ceiling": 0, - "currency": MockERC20, + "currency": WIP_TOKEN_ADDRESS, # Use WIP tokens for payments "uri": "", }, "licensing_config": { "is_set": True, - "minting_fee": 100, + "minting_fee": 100, # Base minting fee "hook_data": ZERO_ADDRESS, "licensing_hook": ZERO_ADDRESS, "commercial_rev_share": 0, @@ -345,99 +244,49 @@ def setup_claim_all_revenue_claim_options(self, story_client: StoryClient): } ] - # Create unique metadata for each IP - metadata_a = { - "ip_metadata_uri": "test-uri-a", - "ip_metadata_hash": web3.to_hex(web3.keccak(text="test-metadata-hash-a")), - "nft_metadata_uri": "test-nft-uri-a", - "nft_metadata_hash": web3.to_hex( - web3.keccak(text="test-nft-metadata-hash-a") - ), - } - - metadata_b = { - "ip_metadata_uri": "test-uri-b", - "ip_metadata_hash": web3.to_hex(web3.keccak(text="test-metadata-hash-b")), - "nft_metadata_uri": "test-nft-uri-b", - "nft_metadata_hash": web3.to_hex( - web3.keccak(text="test-nft-metadata-hash-b") - ), - } - - metadata_c = { - "ip_metadata_uri": "test-uri-c", - "ip_metadata_hash": web3.to_hex(web3.keccak(text="test-metadata-hash-c")), - "nft_metadata_uri": "test-nft-uri-c", - "nft_metadata_hash": web3.to_hex( - web3.keccak(text="test-nft-metadata-hash-c") - ), - } - - metadata_d = { - "ip_metadata_uri": "test-uri-d", - "ip_metadata_hash": web3.to_hex(web3.keccak(text="test-metadata-hash-d")), - "nft_metadata_uri": "test-nft-uri-d", - "nft_metadata_hash": web3.to_hex( - web3.keccak(text="test-nft-metadata-hash-d") - ), - } - - # Register IP A with PIL terms + # Register IP A with PIL terms (root IP in derivative chain) ip_a_response = story_client.IPAsset.mint_and_register_ip_asset_with_pil_terms( spg_nft_contract=spg_nft_contract, - terms=copy.deepcopy(license_terms_template), - ip_metadata=metadata_a, + terms=license_terms_template, ) ip_a = ip_a_response["ip_id"] license_terms_id = ip_a_response["license_terms_ids"][0] - # Register IP B as derivative of A - ip_b_response = story_client.IPAsset.mint_and_register_ip( - spg_nft_contract=spg_nft_contract, ip_metadata=metadata_b - ) - ip_b = ip_b_response["ip_id"] - story_client.IPAsset.register_derivative( - child_ip_id=ip_b, parent_ip_ids=[ip_a], license_terms_ids=[license_terms_id] - ) - - # Register IP C as derivative of B - ip_c_response = story_client.IPAsset.mint_and_register_ip( - spg_nft_contract=spg_nft_contract, ip_metadata=metadata_c - ) - ip_c = ip_c_response["ip_id"] - story_client.IPAsset.register_derivative( - child_ip_id=ip_c, parent_ip_ids=[ip_b], license_terms_ids=[license_terms_id] - ) - - # Register IP D as derivative of C - ip_d_response = story_client.IPAsset.mint_and_register_ip( - spg_nft_contract=spg_nft_contract, ip_metadata=metadata_d - ) - ip_d = ip_d_response["ip_id"] - story_client.IPAsset.register_derivative( - child_ip_id=ip_d, parent_ip_ids=[ip_c], license_terms_ids=[license_terms_id] - ) - - return {"ip_a": ip_a, "ip_b": ip_b, "ip_c": ip_c, "ip_d": ip_d} - - def test_claim_all_revenue_claim_options( - self, setup_claim_all_revenue_claim_options, story_client: StoryClient - ): - """Test claiming all revenue with specific claim options""" + # Build derivative chain: A -> B -> C -> D + # Each derivative mints with WIP tokens, generating revenue for ancestors + ip_b = wrapper_derivative_with_wip(ip_a, license_terms_id) # B pays 100 WIP + ip_c = wrapper_derivative_with_wip( + ip_b, license_terms_id + ) # C pays 100 WIP (10 to A, 90 to B) + wrapper_derivative_with_wip( + ip_c, license_terms_id + ) # D pays 100 WIP (10 to A, 10 to B, 80 to C) + + # Record wallet WIP balance before claiming + wip_token_balance_before = story_client.WIP.balance_of(address=account.address) + + # Claim all revenue from child IPs for ancestor IP A + # With default options: auto_transfer=True, auto_unwrap=True + # This will automatically unwrap WIP tokens to native tokens response = story_client.Royalty.claim_all_revenue( - ancestor_ip_id=setup_claim_all_revenue_claim_options["ip_a"], - claimer=setup_claim_all_revenue_claim_options["ip_a"], - child_ip_ids=[ - setup_claim_all_revenue_claim_options["ip_b"], - setup_claim_all_revenue_claim_options["ip_c"], - ], + ancestor_ip_id=ip_a, + claimer=ip_a, + child_ip_ids=[ip_b, ip_c], royalty_policies=[ROYALTY_POLICY, ROYALTY_POLICY], - currency_tokens=[MockERC20, MockERC20], - claim_options={"auto_transfer_all_claimed_tokens_from_ip": True}, + currency_tokens=[WIP_TOKEN_ADDRESS, WIP_TOKEN_ADDRESS], ) + # Record wallet WIP balance after claiming + wip_token_balance_after = story_client.WIP.balance_of(address=account.address) + + # Verify the claim response assert response is not None assert "tx_hashes" in response assert isinstance(response["tx_hashes"], list) assert len(response["tx_hashes"]) > 0 + + # Verify IP A received 120 WIP tokens total (100 from B + 10 from C + 10 from D) assert response["claimed_tokens"][0]["amount"] == 120 + + # Verify WIP tokens were automatically unwrapped + assert wip_token_balance_after == wip_token_balance_before diff --git a/tests/unit/resources/test_ip_asset.py b/tests/unit/resources/test_ip_asset.py index 90ba00ba..a09e87df 100644 --- a/tests/unit/resources/test_ip_asset.py +++ b/tests/unit/resources/test_ip_asset.py @@ -2,6 +2,7 @@ import pytest from ens.ens import HexStr +from web3 import Web3 from story_protocol_python_sdk import RoyaltyShareInput from story_protocol_python_sdk.abi.IPAccountImpl.IPAccountImpl_client import ( @@ -1127,7 +1128,6 @@ def test_royalty_vault_address( ip_asset: IPAsset, mock_license_registry_client, mock_parse_ip_registered_event, - mock_get_royalty_vault_address_by_ip_id, ): royalty_shares = [ RoyaltyShareInput(recipient=ACCOUNT_ADDRESS, percentage=50.0), @@ -1137,25 +1137,30 @@ def test_royalty_vault_address( with ( mock_parse_ip_registered_event(), mock_license_registry_client(), - mock_get_royalty_vault_address_by_ip_id(), ): - with patch( - "story_protocol_python_sdk.resources.IPAsset.build_and_send_transaction", - return_value={ - "tx_hash": TX_HASH, - "tx_receipt": { - "logs": [ - { - "topics": [ - ip_asset.web3.keccak( - text="IpRoyaltyVaultDeployed(address,address)" - ) - ], - "data": IP_ID + ADDRESS, - } - ] + with ( + patch( + "story_protocol_python_sdk.resources.IPAsset.build_and_send_transaction", + return_value={ + "tx_hash": TX_HASH, + "tx_receipt": { + "logs": [ + { + "topics": [ + Web3.keccak( + text="IpRoyaltyVaultDeployed(address,address)" + ) + ] + } + ] + }, }, - }, + ), + patch.object( + ip_asset.royalty_module_client.contract.events.IpRoyaltyVaultDeployed, + "process_log", + return_value={"args": {"ipId": IP_ID, "ipRoyaltyVault": ADDRESS}}, + ), ): result = ip_asset.mint_and_register_ip_and_make_derivative_and_distribute_royalty_tokens( spg_nft_contract=ADDRESS, diff --git a/tests/unit/resources/test_royalty.py b/tests/unit/resources/test_royalty.py index 47c620b3..b2384268 100644 --- a/tests/unit/resources/test_royalty.py +++ b/tests/unit/resources/test_royalty.py @@ -1,17 +1,31 @@ from unittest.mock import patch import pytest -from ens.ens import HexStr from web3 import Web3 +from story_protocol_python_sdk.abi.IPAccountImpl.IPAccountImpl_client import ( + IPAccountImplClient, +) from story_protocol_python_sdk.resources.Royalty import Royalty -from tests.integration.config.test_config import account, web3 +from story_protocol_python_sdk.utils.constants import WIP_TOKEN_ADDRESS +from tests.unit.fixtures.data import ACCOUNT_ADDRESS, ADDRESS, CHAIN_ID, TX_HASH -@pytest.fixture -def royalty_client(): - chain_id = 1315 - return Royalty(web3, account, chain_id) +@pytest.fixture(scope="class") +def royalty_client(mock_web3, mock_account): + return Royalty(mock_web3, mock_account, CHAIN_ID) + + +@pytest.fixture(scope="class") +def mock_is_registered(royalty_client: Royalty): + def _mock(is_registered: bool = False): + return patch.object( + royalty_client.ip_asset_registry_client, + "isRegistered", + return_value=is_registered, + ) + + return _mock def test_claimable_revenue_royalty_vault_ip_id_error(royalty_client): @@ -19,13 +33,15 @@ def test_claimable_revenue_royalty_vault_ip_id_error(royalty_client): royalty_client.ip_asset_registry_client, "isRegistered", return_value=False ): child_ip_id = "0xA34611b0E11Bba2b11c69864f7D36aC83D862A9c" - account_address = account.address - token = "0xB132A6B7AE652c974EE1557A3521D53d18F6739f" with pytest.raises( ValueError, match=f"The IP with id {child_ip_id} is not registered." ): - royalty_client.claimable_revenue(child_ip_id, account_address, token) + royalty_client.claimable_revenue( + child_ip_id, + ACCOUNT_ADDRESS, + "0xB132A6B7AE652c974EE1557A3521D53d18F6739f", + ) def test_claimable_revenue_success(royalty_client): @@ -41,12 +57,11 @@ def test_claimable_revenue_success(royalty_client): "story_protocol_python_sdk.abi.IpRoyaltyVaultImpl.IpRoyaltyVaultImpl_client.IpRoyaltyVaultImplClient.claimableRevenue", return_value=0, ): - parent_ip_id = "0xA34611b0E11Bba2b11c69864f7D36aC83D862A9c" - account_address = account.address - token = "0xB132A6B7AE652c974EE1557A3521D53d18F6739f" response = royalty_client.claimable_revenue( - parent_ip_id, account_address, token + "0xA34611b0E11Bba2b11c69864f7D36aC83D862A9c", + ACCOUNT_ADDRESS, + "0xB132A6B7AE652c974EE1557A3521D53d18F6739f", ) assert response == 0 @@ -56,16 +71,15 @@ def test_pay_royalty_on_behalf_receiver_ip_id_error(royalty_client): royalty_client.ip_asset_registry_client, "isRegistered", return_value=False ): receiver_ip_id = "0xA34611b0E11Bba2b11c69864f7D36aC83D862A9c" - payer_ip_id = "0x9C098DF37b2324aaC8792dDc7BcEF7Bb0057A9C7" - ERC20 = "0xB132A6B7AE652c974EE1557A3521D53d18F6739f" - amount = 1 - with pytest.raises( ValueError, match=f"The receiver IP with id {receiver_ip_id} is not registered.", ): royalty_client.pay_royalty_on_behalf( - receiver_ip_id, payer_ip_id, ERC20, amount + receiver_ip_id, + "0x9C098DF37b2324aaC8792dDc7BcEF7Bb0057A9C7", + "0xB132A6B7AE652c974EE1557A3521D53d18F6739f", + 1, ) @@ -101,33 +115,541 @@ def test_pay_royalty_on_behalf_success(royalty_client): "gasPrice": Web3.to_wei("300", "gwei"), }, ): + + response = royalty_client.pay_royalty_on_behalf( + "0xA34611b0E11Bba2b11c69864f7D36aC83D862A9c", + "0x9C098DF37b2324aaC8792dDc7BcEF7Bb0057A9C7", + "0xB132A6B7AE652c974EE1557A3521D53d18F6739f", + 1, + ) + assert response is not None + assert "tx_hash" in response + assert response["tx_hash"] == TX_HASH.hex() + + +class TestClaimAllRevenue: + + @pytest.fixture(scope="class") + def mock_parse_tx_revenue_token_claimed_event(self, royalty_client: Royalty): + return patch.object( + royalty_client, + "_parse_tx_revenue_token_claimed_event", + return_value=[ + { + "claimer": ACCOUNT_ADDRESS, + "token": WIP_TOKEN_ADDRESS, + "amount": 120, + } + ], + ) + + @pytest.fixture(scope="class") + def mock_ip_account_owner(self): + def _mock(owner: str = ACCOUNT_ADDRESS): + return patch.object( + IPAccountImplClient, + "owner", + return_value=owner, + ) + + return _mock + + def test_claim_all_revenue_invalid_ancestor_ip_id(self, royalty_client: Royalty): + ancestor_ip_id = "invalid_address" + with pytest.raises( + ValueError, + match=f"Invalid address: {ancestor_ip_id}.", + ): + royalty_client.claim_all_revenue( + ancestor_ip_id=ancestor_ip_id, + claimer=ACCOUNT_ADDRESS, + child_ip_ids=[], + royalty_policies=[], + currency_tokens=[], + ) + + def test_claim_all_revenue_invalid_claimer(self, royalty_client): + ancestor_ip_id = "0xA34611b0E11Bba2b11c69864f7D36aC83D862A9c" + claimer = "invalid_address" + + with pytest.raises( + ValueError, + match="Failed to claim all revenue: Invalid address: invalid_address.", + ): + royalty_client.claim_all_revenue( + ancestor_ip_id=ancestor_ip_id, + claimer=claimer, + child_ip_ids=[], + royalty_policies=[], + currency_tokens=[], + ) + + def test_claim_all_revenue_invalid_child_ip_id(self, royalty_client): + ancestor_ip_id = "0xA34611b0E11Bba2b11c69864f7D36aC83D862A9c" + claimer = "0xA34611b0E11Bba2b11c69864f7D36aC83D862A9c" + child_ip_ids = ["invalid_address"] + + with pytest.raises( + ValueError, + match=r"Failed to claim all revenue: Invalid addresses: \['invalid_address'\]\.", + ): + royalty_client.claim_all_revenue( + ancestor_ip_id=ancestor_ip_id, + claimer=claimer, + child_ip_ids=child_ip_ids, + royalty_policies=[], + currency_tokens=[], + ) + + def test_claim_all_revenue_invalid_royalty_policy(self, royalty_client): + ancestor_ip_id = "0xA34611b0E11Bba2b11c69864f7D36aC83D862A9c" + claimer = "0xA34611b0E11Bba2b11c69864f7D36aC83D862A9c" + child_ip_ids = ["0x9C098DF37b2324aaC8792dDc7BcEF7Bb0057A9C7"] + royalty_policies = ["invalid_address"] + + with pytest.raises( + ValueError, + match=r"Failed to claim all revenue: Invalid addresses: \['invalid_address'\]\.", + ): + royalty_client.claim_all_revenue( + ancestor_ip_id=ancestor_ip_id, + claimer=claimer, + child_ip_ids=child_ip_ids, + royalty_policies=royalty_policies, + currency_tokens=[], + ) + + def test_claim_all_revenue_invalid_currency_token(self, royalty_client): + ancestor_ip_id = "0xA34611b0E11Bba2b11c69864f7D36aC83D862A9c" + claimer = "0xA34611b0E11Bba2b11c69864f7D36aC83D862A9c" + child_ip_ids = ["0x9C098DF37b2324aaC8792dDc7BcEF7Bb0057A9C7"] + royalty_policies = ["0xBe54FB168b3c982b7AaE60dB6CF75Bd8447b390E"] + currency_tokens = ["invalid_address"] + + with pytest.raises( + ValueError, + match=r"Failed to claim all revenue: Invalid addresses: \['invalid_address'\]\.", + ): + royalty_client.claim_all_revenue( + ancestor_ip_id=ancestor_ip_id, + claimer=claimer, + child_ip_ids=child_ip_ids, + royalty_policies=royalty_policies, + currency_tokens=currency_tokens, + ) + + def test_claim_all_revenue_success_with_default_claim_options_and_is_claimer_ip_and_own_claimer( + self, + royalty_client: Royalty, + mock_is_registered, + mock_ip_account_owner, + mock_parse_tx_revenue_token_claimed_event, + ): + """Test claim_all_revenue with default options (auto_transfer=True, auto_unwrap=True)""" + TRANSFER_TX_HASH = b"transfer_tx_hash_bytes" + WITHDRAW_TX_HASH = b"withdraw_tx_hash_bytes" + with mock_is_registered(True), mock_ip_account_owner( + ACCOUNT_ADDRESS + ), mock_parse_tx_revenue_token_claimed_event: with patch( - "web3.eth.Eth.send_raw_transaction", - return_value=Web3.to_bytes( - hexstr=HexStr( - "0xbadf64f2c220e27407c4d2ccbc772fb72c7dc590ac25000dc316e4dc519fbfa2" - ) - ), + "story_protocol_python_sdk.resources.Royalty.build_and_send_transaction", + side_effect=[ + {"tx_hash": TX_HASH.hex(), "tx_receipt": {"status": 1}}, + {"tx_hash": TRANSFER_TX_HASH.hex(), "tx_receipt": {"status": 1}}, + {"tx_hash": WITHDRAW_TX_HASH.hex(), "tx_receipt": {"status": 1}}, + ], + ) as mock_build_and_send: + response = royalty_client.claim_all_revenue( + ancestor_ip_id="0xA34611b0E11Bba2b11c69864f7D36aC83D862A9c", + claimer=ACCOUNT_ADDRESS, + child_ip_ids=["0x9C098DF37b2324aaC8792dDc7BcEF7Bb0057A9C7"], + royalty_policies=["0xBe54FB168b3c982b7AaE60dB6CF75Bd8447b390E"], + currency_tokens=["0xB132A6B7AE652c974EE1557A3521D53d18F6739f"], + ) + assert mock_build_and_send.call_count == 3 + assert response is not None + assert "tx_hashes" in response + assert response["tx_hashes"] == [ + TX_HASH.hex(), + TRANSFER_TX_HASH.hex(), + WITHDRAW_TX_HASH.hex(), + ] + assert "claimed_tokens" in response + assert len(response["claimed_tokens"]) == 1 + assert response["claimed_tokens"][0]["amount"] == 120 + + def test_claim_all_revenue_with_default_claim_options_and_claimer_is_ip_and_owns_claimer_and_token_is_not_wip( + self, + royalty_client: Royalty, + mock_is_registered, + mock_ip_account_owner, + ): + """Test claim_all_revenue with default options and claimer is ip and owns claimer and token is not wip""" + TRANSFER_TX_HASH = b"transfer_tx_hash_bytes" + with mock_is_registered(True), mock_ip_account_owner(ACCOUNT_ADDRESS): + with patch.object( + royalty_client, + "_parse_tx_revenue_token_claimed_event", + return_value=[ + { + "claimer": ACCOUNT_ADDRESS, + "token": ADDRESS, + "amount": 120, + }, + ], + ), patch( + "story_protocol_python_sdk.resources.Royalty.build_and_send_transaction", + side_effect=[ + {"tx_hash": TX_HASH.hex(), "tx_receipt": {"status": 1}}, + {"tx_hash": TRANSFER_TX_HASH.hex(), "tx_receipt": {"status": 1}}, + ], + ) as mock_build_and_send: + response = royalty_client.claim_all_revenue( + ancestor_ip_id="0xA34611b0E11Bba2b11c69864f7D36aC83D862A9c", + claimer=ACCOUNT_ADDRESS, + child_ip_ids=["0x9C098DF37b2324aaC8792dDc7BcEF7Bb0057A9C7"], + royalty_policies=["0xBe54FB168b3c982b7AaE60dB6CF75Bd8447b390E"], + currency_tokens=["0xB132A6B7AE652c974EE1557A3521D53d18F6739f"], + ) + assert mock_build_and_send.call_count == 2 + assert response is not None + assert "tx_hashes" in response + assert response["tx_hashes"] == [TX_HASH.hex(), TRANSFER_TX_HASH.hex()] + assert "claimed_tokens" in response + assert len(response["claimed_tokens"]) == 1 + assert response["claimed_tokens"][0]["amount"] == 120 + + def test_claim_all_revenue_with_default_claim_options_and_claimer_is_ip_and_own_claimer_and_tokens_have_multiple_wip( + self, + royalty_client: Royalty, + mock_is_registered, + mock_ip_account_owner, + mock_parse_tx_revenue_token_claimed_event, + ): + """Test claim_all_revenue with default options and claimer is ip and owns claimer and tokens have multiple wip""" + TRANSFER_TX_HASH = b"transfer_tx_hash_bytes" + with mock_is_registered(True), mock_ip_account_owner( + ACCOUNT_ADDRESS + ), mock_parse_tx_revenue_token_claimed_event: + with patch( + "story_protocol_python_sdk.resources.Royalty.build_and_send_transaction", + side_effect=[ + {"tx_hash": TX_HASH.hex(), "tx_receipt": {"status": 1}}, + {"tx_hash": TRANSFER_TX_HASH.hex(), "tx_receipt": {"status": 1}}, + ], + ), patch.object( + royalty_client, + "_parse_tx_revenue_token_claimed_event", + return_value=[ + { + "claimer": ACCOUNT_ADDRESS, + "token": WIP_TOKEN_ADDRESS, + "amount": 120, + }, + { + "claimer": ACCOUNT_ADDRESS, + "token": WIP_TOKEN_ADDRESS, + "amount": 120, + }, + ], ): - with patch( - "web3.eth.Eth.wait_for_transaction_receipt", - return_value={"status": 1, "logs": []}, + with pytest.raises( + ValueError, + match="Failed to claim all revenue: Multiple WIP tokens found in the claimed tokens.", ): - receiver_ip_id = "0xA34611b0E11Bba2b11c69864f7D36aC83D862A9c" - payer_ip_id = "0x9C098DF37b2324aaC8792dDc7BcEF7Bb0057A9C7" - ERC20 = "0xB132A6B7AE652c974EE1557A3521D53d18F6739f" - amount = 1 - - response = royalty_client.pay_royalty_on_behalf( - receiver_ip_id, - payer_ip_id, - ERC20, - amount, - tx_options={"wait_for_transaction": True}, - ) - assert response is not None - assert "tx_hash" in response - assert ( - response["tx_hash"] - == "badf64f2c220e27407c4d2ccbc772fb72c7dc590ac25000dc316e4dc519fbfa2" + royalty_client.claim_all_revenue( + ancestor_ip_id="0xA34611b0E11Bba2b11c69864f7D36aC83D862A9c", + claimer=ACCOUNT_ADDRESS, + child_ip_ids=["0x9C098DF37b2324aaC8792dDc7BcEF7Bb0057A9C7"], + royalty_policies=["0xBe54FB168b3c982b7AaE60dB6CF75Bd8447b390E"], + currency_tokens=["0xB132A6B7AE652c974EE1557A3521D53d18F6739f"], ) + + def test_claim_all_revenue_with_default_claim_options_and_claimer_is_ip_and_own_claimer_and_token_amount_is_zero( + self, + royalty_client: Royalty, + mock_is_registered, + mock_ip_account_owner, + ): + """Test claim_all_revenue with default options and claimer is ip and owns claimer and token amount is zero""" + with mock_is_registered(True), mock_ip_account_owner(ACCOUNT_ADDRESS): + with patch( + "story_protocol_python_sdk.resources.Royalty.build_and_send_transaction", + return_value={ + "tx_hash": TX_HASH.hex(), + "tx_receipt": { + "logs": [ + { + "topics": [ + Web3.keccak( + text="RevenueTokenClaimed(address,address,uint256)" + ) + ], + }, + ], + }, + }, + ) as mock_build_and_send, patch.object( + royalty_client.ip_royalty_vault_client.contract.events.RevenueTokenClaimed, + "process_log", + return_value={ + "args": { + "claimer": ACCOUNT_ADDRESS, + "token": WIP_TOKEN_ADDRESS, + "amount": 0, + }, + }, + ): + response = royalty_client.claim_all_revenue( + ancestor_ip_id="0xA34611b0E11Bba2b11c69864f7D36aC83D862A9c", + claimer=ACCOUNT_ADDRESS, + child_ip_ids=["0x9C098DF37b2324aaC8792dDc7BcEF7Bb0057A9C7"], + royalty_policies=["0xBe54FB168b3c982b7AaE60dB6CF75Bd8447b390E"], + currency_tokens=["0xB132A6B7AE652c974EE1557A3521D53d18F6739f"], + ) + assert mock_build_and_send.call_count == 1 + assert response is not None + assert "tx_hashes" in response + assert response["tx_hashes"] == [TX_HASH.hex()] + assert "claimed_tokens" in response + assert len(response["claimed_tokens"]) == 1 + assert response["claimed_tokens"][0]["amount"] == 0 + + def test_claim_all_revenue_with_default_claim_options_and_claimer_is_ip_and_not_own_claimer( + self, + royalty_client: Royalty, + mock_is_registered, + mock_ip_account_owner, + mock_parse_tx_revenue_token_claimed_event, + ): + """Test claim_all_revenue with default options (auto_transfer=True, auto_unwrap=True)""" + with mock_is_registered(True), mock_ip_account_owner( + ADDRESS + ), mock_parse_tx_revenue_token_claimed_event: + with patch( + "story_protocol_python_sdk.resources.Royalty.build_and_send_transaction", + return_value={"tx_hash": TX_HASH.hex(), "tx_receipt": {"status": 1}}, + ) as mock_build_and_send: + response = royalty_client.claim_all_revenue( + ancestor_ip_id="0xA34611b0E11Bba2b11c69864f7D36aC83D862A9c", + claimer=ADDRESS, + child_ip_ids=["0x9C098DF37b2324aaC8792dDc7BcEF7Bb0057A9C7"], + royalty_policies=["0xBe54FB168b3c982b7AaE60dB6CF75Bd8447b390E"], + currency_tokens=["0xB132A6B7AE652c974EE1557A3521D53d18F6739f"], + ) + assert mock_build_and_send.call_count == 1 + assert response is not None + assert "tx_hashes" in response + assert response["tx_hashes"] == [TX_HASH.hex()] + assert "claimed_tokens" in response + assert len(response["claimed_tokens"]) == 1 + assert response["claimed_tokens"][0]["amount"] == 120 + + def test_claim_all_revenue_with_default_claim_options_and_not_claimer_ip_and_own_claimer( + self, + royalty_client: Royalty, + mock_is_registered, + mock_ip_account_owner, + mock_parse_tx_revenue_token_claimed_event, + ): + """Test claim_all_revenue with default options (auto_transfer=True, auto_unwrap=True)""" + WITHDRAW_TX_HASH = b"withdraw_tx_hash_bytes" + with mock_is_registered(), mock_ip_account_owner( + ACCOUNT_ADDRESS + ), mock_parse_tx_revenue_token_claimed_event: + with patch( + "story_protocol_python_sdk.resources.Royalty.build_and_send_transaction", + side_effect=[ + {"tx_hash": TX_HASH.hex(), "tx_receipt": {"status": 1}}, + {"tx_hash": WITHDRAW_TX_HASH.hex(), "tx_receipt": {"status": 1}}, + ], + ) as mock_build_and_send: + response = royalty_client.claim_all_revenue( + ancestor_ip_id="0xA34611b0E11Bba2b11c69864f7D36aC83D862A9c", + claimer=ACCOUNT_ADDRESS, + child_ip_ids=["0x9C098DF37b2324aaC8792dDc7BcEF7Bb0057A9C7"], + royalty_policies=["0xBe54FB168b3c982b7AaE60dB6CF75Bd8447b390E"], + currency_tokens=["0xB132A6B7AE652c974EE1557A3521D53d18F6739f"], + ) + assert ( + mock_build_and_send.call_count == 2 + ) # claim_all_revenue and withdraw + assert response is not None + assert "tx_hashes" in response + assert response["tx_hashes"] == [TX_HASH.hex(), WITHDRAW_TX_HASH.hex()] + assert "claimed_tokens" in response + assert len(response["claimed_tokens"]) == 1 + assert response["claimed_tokens"][0]["amount"] == 120 + + def test_claim_all_revenue_with_auto_transfer_false_and_own_claimer( + self, + royalty_client: Royalty, + mock_is_registered, + mock_ip_account_owner, + mock_parse_tx_revenue_token_claimed_event, + ): + """Test claim_all_revenue with auto_transfer_all_claimed_tokens_from_ip=False""" + WITHDRAW_TX_HASH = b"withdraw_tx_hash_bytes" + with mock_is_registered(True), mock_ip_account_owner( + ACCOUNT_ADDRESS + ), mock_parse_tx_revenue_token_claimed_event: + with patch( + "story_protocol_python_sdk.resources.Royalty.build_and_send_transaction", + side_effect=[ + {"tx_hash": TX_HASH.hex(), "tx_receipt": {"status": 1}}, + {"tx_hash": WITHDRAW_TX_HASH.hex(), "tx_receipt": {"status": 1}}, + ], + ) as mock_build_and_send: + response = royalty_client.claim_all_revenue( + ancestor_ip_id="0xA34611b0E11Bba2b11c69864f7D36aC83D862A9c", + claimer=ACCOUNT_ADDRESS, + child_ip_ids=["0x9C098DF37b2324aaC8792dDc7BcEF7Bb0057A9C7"], + royalty_policies=["0xBe54FB168b3c982b7AaE60dB6CF75Bd8447b390E"], + currency_tokens=["0xB132A6B7AE652c974EE1557A3521D53d18F6739f"], + claim_options={"auto_transfer_all_claimed_tokens_from_ip": False}, + ) + assert mock_build_and_send.call_count == 2 + assert response is not None + assert "tx_hashes" in response + assert response["tx_hashes"] == [TX_HASH.hex(), WITHDRAW_TX_HASH.hex()] + assert "claimed_tokens" in response + assert len(response["claimed_tokens"]) == 1 + + def test_claim_all_revenue_with_auto_transfer_false_and_not_own_claimer( + self, + royalty_client: Royalty, + mock_is_registered, + mock_ip_account_owner, + mock_parse_tx_revenue_token_claimed_event, + ): + """Test claim_all_revenue with auto_transfer_all_claimed_tokens_from_ip=False""" + WITHDRAW_TX_HASH = b"withdraw_tx_hash_bytes" + with mock_is_registered(), mock_ip_account_owner( + ADDRESS + ), mock_parse_tx_revenue_token_claimed_event: + with patch( + "story_protocol_python_sdk.resources.Royalty.build_and_send_transaction", + side_effect=[ + {"tx_hash": TX_HASH.hex(), "tx_receipt": {"status": 1}}, + {"tx_hash": WITHDRAW_TX_HASH.hex(), "tx_receipt": {"status": 1}}, + ], + ) as mock_build_and_send: + response = royalty_client.claim_all_revenue( + ancestor_ip_id="0xA34611b0E11Bba2b11c69864f7D36aC83D862A9c", + claimer=ADDRESS, + child_ip_ids=["0x9C098DF37b2324aaC8792dDc7BcEF7Bb0057A9C7"], + royalty_policies=["0xBe54FB168b3c982b7AaE60dB6CF75Bd8447b390E"], + currency_tokens=["0xB132A6B7AE652c974EE1557A3521D53d18F6739f"], + claim_options={"auto_transfer_all_claimed_tokens_from_ip": False}, + ) + assert mock_build_and_send.call_count == 1 + assert response is not None + assert "tx_hashes" in response + assert response["tx_hashes"] == [TX_HASH.hex()] + assert "claimed_tokens" in response + assert len(response["claimed_tokens"]) == 1 + assert response["claimed_tokens"][0]["amount"] == 120 + + def test_claim_all_revenue_with_auto_unwrap_false_and_own_claimer_ip_and_owns_claimer( + self, + royalty_client: Royalty, + mock_is_registered, + mock_ip_account_owner, + mock_parse_tx_revenue_token_claimed_event, + ): + """Test claim_all_revenue with auto_unwrap_ip_tokens=False""" + TRANSFER_TX_HASH = b"transfer_tx_hash_bytes" + with mock_is_registered(True), mock_ip_account_owner( + ACCOUNT_ADDRESS + ), mock_parse_tx_revenue_token_claimed_event: + with patch( + "story_protocol_python_sdk.resources.Royalty.build_and_send_transaction", + side_effect=[ + {"tx_hash": TX_HASH.hex(), "tx_receipt": {"status": 1}}, + {"tx_hash": TRANSFER_TX_HASH.hex(), "tx_receipt": {"status": 1}}, + ], + ) as mock_build_and_send: + response = royalty_client.claim_all_revenue( + ancestor_ip_id="0xA34611b0E11Bba2b11c69864f7D36aC83D862A9c", + claimer=ACCOUNT_ADDRESS, + child_ip_ids=["0x9C098DF37b2324aaC8792dDc7BcEF7Bb0057A9C7"], + royalty_policies=["0xBe54FB168b3c982b7AaE60dB6CF75Bd8447b390E"], + currency_tokens=["0xB132A6B7AE652c974EE1557A3521D53d18F6739f"], + claim_options={"auto_unwrap_ip_tokens": False}, + ) + assert mock_build_and_send.call_count == 2 + assert response is not None + assert "tx_hashes" in response + assert response["tx_hashes"] == [TX_HASH.hex(), TRANSFER_TX_HASH.hex()] + assert "claimed_tokens" in response + assert len(response["claimed_tokens"]) == 1 + assert response["claimed_tokens"][0]["amount"] == 120 + + def test_claim_all_revenue_with_auto_unwrap_false_and_own_claimer_ip_and_not_owns_claimer( + self, + royalty_client: Royalty, + mock_is_registered, + mock_ip_account_owner, + mock_parse_tx_revenue_token_claimed_event, + ): + """Test claim_all_revenue with auto_unwrap_ip_tokens=False""" + TRANSFER_TX_HASH = b"transfer_tx_hash_bytes" + with mock_is_registered(True), mock_ip_account_owner( + ADDRESS + ), mock_parse_tx_revenue_token_claimed_event: + with patch( + "story_protocol_python_sdk.resources.Royalty.build_and_send_transaction", + side_effect=[ + {"tx_hash": TX_HASH.hex(), "tx_receipt": {"status": 1}}, + {"tx_hash": TRANSFER_TX_HASH.hex(), "tx_receipt": {"status": 1}}, + ], + ) as mock_build_and_send: + response = royalty_client.claim_all_revenue( + ancestor_ip_id="0xA34611b0E11Bba2b11c69864f7D36aC83D862A9c", + claimer=ACCOUNT_ADDRESS, + child_ip_ids=["0x9C098DF37b2324aaC8792dDc7BcEF7Bb0057A9C7"], + royalty_policies=["0xBe54FB168b3c982b7AaE60dB6CF75Bd8447b390E"], + currency_tokens=["0xB132A6B7AE652c974EE1557A3521D53d18F6739f"], + claim_options={"auto_unwrap_ip_tokens": False}, + ) + assert mock_build_and_send.call_count == 1 + assert response is not None + assert "tx_hashes" in response + assert response["tx_hashes"] == [TX_HASH.hex()] + assert "claimed_tokens" in response + assert len(response["claimed_tokens"]) == 1 + assert response["claimed_tokens"][0]["amount"] == 120 + + def test_claim_all_revenue_with_auto_unwrap_false_and_not_own_claimer_ip_and_owns_claimer( + self, + royalty_client: Royalty, + mock_is_registered, + mock_ip_account_owner, + mock_parse_tx_revenue_token_claimed_event, + ): + """Test claim_all_revenue with auto_unwrap_ip_tokens=False""" + TRANSFER_TX_HASH = b"transfer_tx_hash_bytes" + with mock_is_registered(), mock_ip_account_owner( + ACCOUNT_ADDRESS + ), mock_parse_tx_revenue_token_claimed_event: + with patch( + "story_protocol_python_sdk.resources.Royalty.build_and_send_transaction", + side_effect=[ + {"tx_hash": TX_HASH.hex(), "tx_receipt": {"status": 1}}, + {"tx_hash": TRANSFER_TX_HASH.hex(), "tx_receipt": {"status": 1}}, + ], + ) as mock_build_and_send: + response = royalty_client.claim_all_revenue( + ancestor_ip_id="0xA34611b0E11Bba2b11c69864f7D36aC83D862A9c", + claimer=ADDRESS, + child_ip_ids=["0x9C098DF37b2324aaC8792dDc7BcEF7Bb0057A9C7"], + royalty_policies=["0xBe54FB168b3c982b7AaE60dB6CF75Bd8447b390E"], + currency_tokens=["0xB132A6B7AE652c974EE1557A3521D53d18F6739f"], + claim_options={"auto_unwrap_ip_tokens": False}, + ) + assert mock_build_and_send.call_count == 1 + assert response is not None + assert "tx_hashes" in response + assert response["tx_hashes"] == [TX_HASH.hex()] + assert "claimed_tokens" in response + assert len(response["claimed_tokens"]) == 1