From 0c65c3b5f3569467ebb6c3252441c8e5fc196d58 Mon Sep 17 00:00:00 2001 From: Andrew Chung <58543094+aandrewchung@users.noreply.github.com> Date: Wed, 26 Feb 2025 02:59:28 +0900 Subject: [PATCH 01/17] [UPDATE] New Release & Register() Metadata Bug Fix (#38) * Updated demo to aeneid * Fixed metadata camelcase bug * Updated readme to aeneid * manual upload of new ver to pypi --- README.md | 10 ++-- setup.py | 4 +- .../resources/IPAsset.py | 12 ++-- tests/demo/demo.py | 58 +++++++------------ tests/demo/demo_utils.py | 42 ++++++++------ .../integration/test_integration_ip_asset.py | 16 ++++- 6 files changed, 72 insertions(+), 70 deletions(-) diff --git a/README.md b/README.md index aa6c49e..a78c136 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# Story Protocol SDK +# Story SDK -Welcome to the documents for Story Protocol Python SDK. The Python SDK provides the APIs for developers to build applications with Story Protocol. By using the SDK, developers can create the resources like IP assets and perform actions to interact with the resource. +Welcome to the documents for Story Python SDK. The Python SDK provides the APIs for developers to build applications with Story. By using the SDK, developers can create the resources like IP assets and perform actions to interact with the resource. ## How to use Story Protocol SDK in Your Project @@ -48,14 +48,14 @@ The preceding code created the `account` object for creating the SDK client. To set up the SDK client, import `StoryClient` from `story_protocol_python_sdk`. Write the following code, utilizing the `account` we created previously. -> :information-source: Make sure to have RPC_PROVIDER_URL for your desired chain set up in your .env file. We recommend using the Sepolia network with `RPC_PROVIDER_URL=https://rpc.ankr.com/eth_sepolia`. +> :information-source: Make sure to have RPC_PROVIDER_URL for your desired chain set up in your .env file. We recommend using the public default one with `RPC_PROVIDER_URL=https://aeneid.storyrpc.io`. ```Python main.py from story_protocol_python_sdk import StoryClient # Create StoryClient instance -odyssey_chain_id = 1516 -story_client = StoryClient(web3, account, odyssey_chain_id) +aeneid_chain_id = 1315 +story_client = StoryClient(web3, account, aeneid_chain_id) ``` ## Running test cases diff --git a/setup.py b/setup.py index 9b46a12..655e893 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ setup( name='story_protocol_python_sdk', - version='0.3.5', + version='0.3.7', packages=find_packages(where='src', exclude=["tests"]), package_dir={'': 'src'}, install_requires=[ @@ -18,7 +18,7 @@ license='MIT', author='Andrew Chung', author_email='andrew@storyprotocol.xyz', - description='A Python SDK for interacting with the Story Protocol.', + description='A Python SDK for interacting with Story.', long_description=open('README.md').read(), long_description_content_type='text/markdown', classifiers=[ diff --git a/src/story_protocol_python_sdk/resources/IPAsset.py b/src/story_protocol_python_sdk/resources/IPAsset.py index 539a835..23f99a8 100644 --- a/src/story_protocol_python_sdk/resources/IPAsset.py +++ b/src/story_protocol_python_sdk/resources/IPAsset.py @@ -62,9 +62,9 @@ def register( :param nft_contract str: The address of the NFT. :param token_id int: The token identifier of the NFT. :param ip_metadata dict: [Optional] Metadata for the IP. - :param ip_metadata_URI str: [Optional] Metadata URI for the IP. + :param ip_metadata_uri str: [Optional] Metadata URI for the IP. :param ip_metadata_hash str: [Optional] Metadata hash for the IP. - :param nft_metadata_URI str: [Optional] Metadata URI for the NFT. + :param nft_metadata_uri str: [Optional] Metadata URI for the NFT. :param nft_metadata_hash str: [Optional] Metadata hash for the NFT. :param deadline int: [Optional] Signature deadline in milliseconds. :param tx_options dict: [Optional] Transaction options. @@ -96,10 +96,10 @@ def register( if ip_metadata: req_object['ipMetadata'].update({ - 'ipMetadataURI': ip_metadata.get('ipMetadataURI', ""), - 'ipMetadataHash': ip_metadata.get('ipMetadataHash', ZERO_HASH), - 'nftMetadataURI': ip_metadata.get('nftMetadataURI', ""), - 'nftMetadataHash': ip_metadata.get('nftMetadataHash', ZERO_HASH), + 'ipMetadataURI': ip_metadata.get('ip_metadata_uri', ""), + 'ipMetadataHash': ip_metadata.get('ip_metadata_hash', ZERO_HASH), + 'nftMetadataURI': ip_metadata.get('nft_metadata_uri', ""), + 'nftMetadataHash': ip_metadata.get('nft_metadata_hash', ZERO_HASH), }) calculated_deadline = self.sign_util.get_deadline(deadline=deadline) diff --git a/tests/demo/demo.py b/tests/demo/demo.py index 81015f3..4b08035 100644 --- a/tests/demo/demo.py +++ b/tests/demo/demo.py @@ -3,7 +3,7 @@ from web3 import Web3 from story_protocol_python_sdk import StoryClient -from demo_utils import get_token_id, MockERC721, MockERC20, mint_tokens +from demo_utils import get_token_id, MockERC721, MockERC20, mint_tokens, ROYALTY_POLICY, ROYALTY_MODULE, PIL_LICENSE_TEMPLATE def main(): # 1. Set up your Story Config @@ -11,6 +11,11 @@ def main(): private_key = os.getenv('WALLET_PRIVATE_KEY') rpc_url = os.getenv('RPC_PROVIDER_URL') + if not private_key: + raise ValueError("WALLET_PRIVATE_KEY environment variable is not set") + if not rpc_url: + raise ValueError("RPC_PROVIDER_URL environment variable is not set") + # Initialize Web3 web3 = Web3(Web3.HTTPProvider(rpc_url)) if not web3.is_connected(): @@ -20,26 +25,22 @@ def main(): account = web3.eth.account.from_key(private_key) # Create StoryClient instance - story_client = StoryClient(web3, account, 11155111) + story_client = StoryClient(web3, account, 1315) # 2. Register an IP Asset token_id = get_token_id(MockERC721, story_client.web3, story_client.account) + registered_ip_asset_response = story_client.IPAsset.register( - token_contract=MockERC721, + nft_contract=MockERC721, token_id=token_id ) print(f"Root IPA created at transaction hash {registered_ip_asset_response['txHash']}, IPA ID: {registered_ip_asset_response['ipId']}") # 3. Register PIL Terms - commercial_use_params = { - 'currency': MockERC20, - 'minting_fee': 2, - 'royalty_policy': "0xAAbaf349C7a2A84564F9CC4Ac130B3f19A718E86" - } register_pil_terms_response = story_client.License.registerCommercialUsePIL( - minting_fee=commercial_use_params['minting_fee'], - currency=commercial_use_params['currency'], - royalty_policy=commercial_use_params['royalty_policy'] + default_minting_fee=1, + currency=MockERC20, + royalty_policy=ROYALTY_POLICY ) if 'txHash' in register_pil_terms_response: print(f"PIL Terms registered at transaction hash {register_pil_terms_response['txHash']}, License Terms ID: {register_pil_terms_response['licenseTermsId']}") @@ -50,7 +51,7 @@ def main(): try: attach_license_terms_response = story_client.License.attachLicenseTerms( ip_id=registered_ip_asset_response['ipId'], - license_template="0x260B6CB6284c89dbE660c0004233f7bB99B5edE7", + license_template=PIL_LICENSE_TEMPLATE, license_terms_id=register_pil_terms_response['licenseTermsId'] ) print(f"Attached License Terms to IP at transaction hash {attach_license_terms_response['txHash']}") @@ -58,51 +59,34 @@ def main(): print(f"License Terms ID {register_pil_terms_response['licenseTermsId']} already attached to this IPA.") #Before you mint make sure you have enough ERC20 tokens according to the minting fee above - token_ids = mint_tokens(MockERC20, web3, account, account.address, 2) + token_ids = mint_tokens(MockERC20, web3, account, account.address, 10000) # 5. Mint License mint_license_response = story_client.License.mintLicenseTokens( licensor_ip_id=registered_ip_asset_response['ipId'], - license_template="0x260B6CB6284c89dbE660c0004233f7bB99B5edE7", + license_template=PIL_LICENSE_TEMPLATE, license_terms_id=register_pil_terms_response['licenseTermsId'], amount=1, - receiver=account.address + receiver=account.address, + max_minting_fee=1, + max_revenue_share=0 ) print(f"License Token minted at transaction hash {mint_license_response['txHash']}, License Token IDs: {mint_license_response['licenseTokenIds']}") # 6. Mint derivative IP Asset using your license derivative_token_id = get_token_id(MockERC721, story_client.web3, story_client.account) registered_ip_asset_derivative_response = story_client.IPAsset.register( - token_contract=MockERC721, + nft_contract=MockERC721, token_id=derivative_token_id ) print(f"Derivative IPA created at transaction hash {registered_ip_asset_derivative_response['txHash']}, IPA ID: {registered_ip_asset_derivative_response['ipId']}") link_derivative_response = story_client.IPAsset.registerDerivativeWithLicenseTokens( child_ip_id=registered_ip_asset_derivative_response['ipId'], - license_token_ids=mint_license_response['licenseTokenIds'] + license_token_ids=mint_license_response['licenseTokenIds'], + max_rts=5 * 10 ** 6 ) print(f"Derivative IPA linked to parent at transaction hash {link_derivative_response['txHash']}") - # 7. Collect Royalty Tokens - collect_royalty_tokens_response = story_client.Royalty.collectRoyaltyTokens( - parent_ip_id=registered_ip_asset_response['ipId'], - child_ip_id=registered_ip_asset_derivative_response['ipId'] - ) - print(f"Collected royalty token {collect_royalty_tokens_response['royaltyTokensCollected']} at transaction hash {collect_royalty_tokens_response['txHash']}") - - # 8. Claim Revenue - snapshot_response = story_client.Royalty.snapshot( - child_ip_id=registered_ip_asset_derivative_response['ipId'] - ) - print(f"Took a snapshot with ID {snapshot_response['snapshotId']} at transaction hash {snapshot_response['txHash']}") - - claim_revenue_response = story_client.Royalty.claimRevenue( - snapshot_ids=[snapshot_response['snapshotId']], - child_ip_id=registered_ip_asset_derivative_response['ipId'], - token=MockERC20 - ) - print(f"Claimed revenue token {claim_revenue_response['claimableToken']} at transaction hash {claim_revenue_response['txHash']}") - if __name__ == "__main__": main() diff --git a/tests/demo/demo_utils.py b/tests/demo/demo_utils.py index e284427..039254f 100644 --- a/tests/demo/demo_utils.py +++ b/tests/demo/demo_utils.py @@ -1,8 +1,13 @@ # Mock ERC721 contract address -MockERC721 = "0x7ee32b8B515dEE0Ba2F25f612A04a731eEc24F49" +MockERC721 = "0xa1119092ea911202E0a65B743a13AE28C5CF2f21" # Mock ERC20 contract address (same as used in TypeScript tests) -MockERC20 = "0xB132A6B7AE652c974EE1557A3521D53d18F6739f" +MockERC20 = "0xF2104833d386a2734a4eB3B8ad6FC6812F29E38E" + +ZERO_ADDRESS = "0x0000000000000000000000000000000000000000" +ROYALTY_POLICY="0xBe54FB168b3c982b7AaE60dB6CF75Bd8447b390E" #Royalty Policy LAP +ROYALTY_MODULE="0xD2f60c40fEbccf6311f8B47c4f2Ec6b040400086" +PIL_LICENSE_TEMPLATE="0x2E896b0b2Fdb7457499B56AAaA4AE55BCB4Cd316" def get_token_id(nft_contract, web3, account): contract_abi = [ @@ -15,23 +20,26 @@ def get_token_id(nft_contract, web3, account): } ] - # Fetch the current average gas price from the node plus 10% - current_gas_price = int(web3.eth.gas_price * 1.1) - contract = web3.eth.contract(address=nft_contract, abi=contract_abi) - transaction = contract.functions.mint(account.address).build_transaction({ - 'from': account.address, - 'nonce': web3.eth.get_transaction_count(account.address), - 'gas': 2000000, - 'gasPrice': current_gas_price - }) - signed_txn = account.sign_transaction(transaction) - tx_hash = web3.eth.send_raw_transaction(signed_txn.raw_transaction) - tx_receipt = web3.eth.wait_for_transaction_receipt(tx_hash) + + try: + transaction = contract.functions.mint(account.address).build_transaction({ + 'from': account.address, + 'nonce': web3.eth.get_transaction_count(account.address), + 'gas': 2000000 + }) + + signed_txn = account.sign_transaction(transaction) + tx_hash = web3.eth.send_raw_transaction(signed_txn.raw_transaction) + tx_receipt = web3.eth.wait_for_transaction_receipt(tx_hash) - logs = tx_receipt['logs'] - if logs[0]['topics'][3]: - return int(logs[0]['topics'][3].hex(), 16) + logs = tx_receipt['logs'] + if len(logs) > 0 and len(logs[0]['topics']) > 3: + return int(logs[0]['topics'][3].hex(), 16) + raise ValueError(f"No token ID in logs: {tx_receipt}") + + except Exception as e: + raise e def mint_tokens(erc20_contract_address, web3, account, to_address, amount): contract_abi = [ diff --git a/tests/integration/test_integration_ip_asset.py b/tests/integration/test_integration_ip_asset.py index b0e0854..4d899d5 100644 --- a/tests/integration/test_integration_ip_asset.py +++ b/tests/integration/test_integration_ip_asset.py @@ -44,9 +44,10 @@ def test_register_ip_asset(story_client, child_ip_id): def test_register_ip_asset_with_metadata(story_client): token_id = get_token_id(MockERC721, story_client.web3, story_client.account) metadata = { - 'metadataURI': "test-uri", - 'metadataHash': web3.to_hex(web3.keccak(text="test-metadata-hash")), - 'nftMetadataHash': web3.to_hex(web3.keccak(text="test-nft-metadata-hash")) + 'ip_metadata_uri': "test-uri", + 'ip_metadata_hash': web3.to_hex(web3.keccak(text="test-metadata-hash")), + 'nft_metadata_uri': "test-nft-uri", + 'nft_metadata_hash': web3.to_hex(web3.keccak(text="test-nft-metadata-hash")) } response = story_client.IPAsset.register( @@ -143,6 +144,14 @@ def nft_collection(story_client): return txData['nftContract'] def test_mint_register_attach_terms(story_client, nft_collection): + + metadata = { + 'ip_metadata_uri': "test-uri", + 'ip_metadata_hash': web3.to_hex(web3.keccak(text="test-metadata-hash")), + 'nft_metadata_uri': "test-nft-uri", + 'nft_metadata_hash': web3.to_hex(web3.keccak(text="test-nft-metadata-hash")) + } + response = story_client.IPAsset.mintAndRegisterIpAssetWithPilTerms( spg_nft_contract=nft_collection, terms=[{ @@ -176,6 +185,7 @@ def test_mint_register_attach_terms(story_client, nft_collection): 'expect_group_reward_pool': ZERO_ADDRESS } }], + ip_metadata=metadata ) assert 'txHash' in response From 3cdf32d8cc701f60524902bca73149065ac39c09 Mon Sep 17 00:00:00 2001 From: Andrew Chung <58543094+aandrewchung@users.noreply.github.com> Date: Fri, 28 Feb 2025 22:51:11 +0900 Subject: [PATCH 02/17] [UPDATE] Added mainnet support (#39) --- src/story_protocol_python_sdk/story_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/story_protocol_python_sdk/story_client.py b/src/story_protocol_python_sdk/story_client.py index 3560d27..49d2fd2 100644 --- a/src/story_protocol_python_sdk/story_client.py +++ b/src/story_protocol_python_sdk/story_client.py @@ -36,7 +36,7 @@ def __init__(self, web3, account, chain_id: int): if not web3 or not account: raise ValueError("web3 and account must be provided") - if chain_id != 1315: + if chain_id not in [1315, 1514]: raise ValueError("only support story devnet") self.web3 = web3 From d83347a50a08080436586ef80bcaf4d1cbe18095 Mon Sep 17 00:00:00 2001 From: Andrew Chung <58543094+aandrewchung@users.noreply.github.com> Date: Thu, 13 Mar 2025 20:32:42 +0900 Subject: [PATCH 03/17] [FEATURE] Added mintAndRegisterIp() (#42) * Added mintandremintAndRegisterIp() and integration test * Updated ip asset integration test to use classes * Fixed missing child_ip bug for deriv integration test --- .../resources/IPAsset.py | 65 ++- .../integration/test_integration_ip_asset.py | 528 +++++++----------- 2 files changed, 275 insertions(+), 318 deletions(-) diff --git a/src/story_protocol_python_sdk/resources/IPAsset.py b/src/story_protocol_python_sdk/resources/IPAsset.py index 23f99a8..ceddc32 100644 --- a/src/story_protocol_python_sdk/resources/IPAsset.py +++ b/src/story_protocol_python_sdk/resources/IPAsset.py @@ -485,7 +485,70 @@ def mintAndRegisterIpAssetWithPilTerms( except Exception as e: raise e - + + def mintAndRegisterIp( + self, + spg_nft_contract: str, + recipient: str = None, + ip_metadata: dict = None, + allow_duplicates: bool = True, + tx_options: dict = None + ) -> dict: + """ + Mint an NFT from a SPGNFT collection and register it with metadata as an IP. + + :param spg_nft_contract str: The address of the SPGNFT collection. + :param recipient str: [Optional] The address of the recipient of the minted NFT, + default value is your wallet address. + :param ip_metadata dict: [Optional] The desired metadata for the newly minted NFT + and newly registered IP. + :param ip_metadata_uri str: [Optional] The URI of the metadata for the IP. + :param ip_metadata_hash str: [Optional] The hash of the metadata for the IP. + :param nft_metadata_uri str: [Optional] The URI of the metadata for the NFT. + :param nft_metadata_hash str: [Optional] The hash of the metadata for the IP NFT. + :param allow_duplicates bool: Set to true to allow minting an NFT with a duplicate + metadata hash. + :param tx_options dict: [Optional] The transaction options. + :return dict: A dictionary with the transaction hash, IP ID and token ID. + """ + try: + metadata = { + 'ipMetadataURI': "", + 'ipMetadataHash': ZERO_HASH, + 'nftMetadataURI': "", + 'nftMetadataHash': ZERO_HASH, + } + + if ip_metadata: + metadata.update({ + 'ipMetadataURI': ip_metadata.get('ip_metadata_uri', ""), + 'ipMetadataHash': ip_metadata.get('ip_metadata_hash', ZERO_HASH), + 'nftMetadataURI': ip_metadata.get('nft_metadata_uri', ""), + 'nftMetadataHash': ip_metadata.get('nft_metadata_hash', ZERO_HASH), + }) + + response = build_and_send_transaction( + self.web3, + self.account, + self.registration_workflows_client.build_mintAndRegisterIp_transaction, + spg_nft_contract, + recipient if recipient else self.account.address, + metadata, + allow_duplicates, + tx_options=tx_options + ) + + ip_registered = self._parse_tx_ip_registered_event(response['txReceipt']) + + return { + 'txHash': response['txHash'], + 'ipId': ip_registered['ipId'], + 'tokenId': ip_registered['tokenId'] + } + + except Exception as e: + raise ValueError(f"Failed to mint and register IP: {str(e)}") + # def registerIpAndAttachPilTerms(self, nft_contract: str, token_id: int, license_terms_data: dict, ip_metadata: dict = None, deadline: int = None, tx_options: dict = None) -> dict: # """ # Register a given NFT as an IP and attach Programmable IP License Terms. diff --git a/tests/integration/test_integration_ip_asset.py b/tests/integration/test_integration_ip_asset.py index 4d899d5..084b9fe 100644 --- a/tests/integration/test_integration_ip_asset.py +++ b/tests/integration/test_integration_ip_asset.py @@ -21,321 +21,215 @@ PIL_LICENSE_TEMPLATE ) -@pytest.fixture(scope="module") -def child_ip_id(story_client): - token_id = get_token_id(MockERC721, story_client.web3, story_client.account) - - response = story_client.IPAsset.register( - nft_contract=MockERC721, - token_id=token_id - ) - - assert 'txHash' in response - assert isinstance(response['txHash'], str) - - assert response is not None - assert 'ipId' in response - assert response['ipId'] is not None - return response['ipId'] - -def test_register_ip_asset(story_client, child_ip_id): - assert child_ip_id is not None - -def test_register_ip_asset_with_metadata(story_client): - token_id = get_token_id(MockERC721, story_client.web3, story_client.account) - metadata = { - 'ip_metadata_uri': "test-uri", - 'ip_metadata_hash': web3.to_hex(web3.keccak(text="test-metadata-hash")), - 'nft_metadata_uri': "test-nft-uri", - 'nft_metadata_hash': web3.to_hex(web3.keccak(text="test-nft-metadata-hash")) - } - - response = story_client.IPAsset.register( - nft_contract=MockERC721, - token_id=token_id, - ip_metadata=metadata, - deadline=1000 - ) - - assert 'txHash' in response - assert isinstance(response['txHash'], str) - - assert response is not None - assert 'ipId' in response - assert response['ipId'] is not None - assert isinstance(response['ipId'], str) - -@pytest.fixture(scope="module") -def non_commercial_license(story_client): - license_register_response = story_client.License.registerNonComSocialRemixingPIL() - no_commercial_license_terms_id = license_register_response['licenseTermsId'] - return no_commercial_license_terms_id - -@pytest.fixture(scope="module") -def parent_ip_id(story_client, non_commercial_license): - token_id = get_token_id(MockERC721, story_client.web3, story_client.account) - response = story_client.IPAsset.register( - nft_contract=MockERC721, - token_id=token_id - ) - - attach_license_response = story_client.License.attachLicenseTerms(response['ipId'], PIL_LICENSE_TEMPLATE, non_commercial_license) - - return response['ipId'] - -def test_register_derivative(story_client, child_ip_id, parent_ip_id, non_commercial_license): - response = story_client.IPAsset.registerDerivative( - child_ip_id=child_ip_id, - parent_ip_ids=[parent_ip_id], - license_terms_ids=[non_commercial_license], - max_minting_fee=0, - max_rts=5 * 10 ** 6, - max_revenue_share=0, - ) +class TestIPAssetRegistration: + @pytest.fixture(scope="module") + def child_ip_id(self, story_client): + token_id = get_token_id(MockERC721, story_client.web3, story_client.account) + + response = story_client.IPAsset.register( + nft_contract=MockERC721, + token_id=token_id + ) + + assert 'txHash' in response + assert isinstance(response['txHash'], str) + + assert response is not None + assert 'ipId' in response + assert response['ipId'] is not None + return response['ipId'] + + def test_register_ip_asset(self, story_client, child_ip_id): + assert child_ip_id is not None + + def test_register_ip_asset_with_metadata(self, story_client): + token_id = get_token_id(MockERC721, story_client.web3, story_client.account) + metadata = { + 'ip_metadata_uri': "test-uri", + 'ip_metadata_hash': web3.to_hex(web3.keccak(text="test-metadata-hash")), + 'nft_metadata_uri': "test-nft-uri", + 'nft_metadata_hash': web3.to_hex(web3.keccak(text="test-nft-metadata-hash")) + } + + response = story_client.IPAsset.register( + nft_contract=MockERC721, + token_id=token_id, + ip_metadata=metadata, + deadline=1000 + ) + + assert 'txHash' in response + assert isinstance(response['txHash'], str) + + assert response is not None + assert 'ipId' in response + assert response['ipId'] is not None + assert isinstance(response['ipId'], str) + +class TestIPAssetDerivatives: + @pytest.fixture(scope="module") + def child_ip_id(self, story_client): + token_id = get_token_id(MockERC721, story_client.web3, story_client.account) + + response = story_client.IPAsset.register( + nft_contract=MockERC721, + token_id=token_id + ) + + assert 'txHash' in response + assert isinstance(response['txHash'], str) + + assert response is not None + assert 'ipId' in response + assert response['ipId'] is not None + return response['ipId'] - assert response is not None - assert 'txHash' in response - assert response['txHash'] is not None - assert isinstance(response['txHash'], str) - assert len(response['txHash']) > 0 - -def test_registerDerivativeWithLicenseTokens(story_client, parent_ip_id, non_commercial_license): - token_id = get_token_id(MockERC721, story_client.web3, story_client.account) - child_response = story_client.IPAsset.register( - nft_contract=MockERC721, - token_id=token_id - ) - child_ip_id = child_response['ipId'] - - license_token_response = story_client.License.mintLicenseTokens( - licensor_ip_id=parent_ip_id, - license_template=PIL_LICENSE_TEMPLATE, - license_terms_id=non_commercial_license, - amount=1, - receiver=account.address, - max_minting_fee=0, - max_revenue_share=1 - ) - licenseTokenIds = license_token_response['licenseTokenIds'] - - response = story_client.IPAsset.registerDerivativeWithLicenseTokens( - child_ip_id=child_ip_id, - license_token_ids=licenseTokenIds, - max_rts=5 * 10 ** 6 - ) - - assert response is not None - assert 'txHash' in response - assert response['txHash'] is not None - assert isinstance(response['txHash'], str) - assert len(response['txHash']) > 0 - -@pytest.fixture(scope="module") -def nft_collection(story_client): - txData = story_client.NFTClient.createNFTCollection( - name="test-collection", - symbol="TEST", - max_supply=100, - is_public_minting=True, - mint_open=True, - contract_uri="test-uri", - mint_fee_recipient=account.address, - ) - return txData['nftContract'] - -def test_mint_register_attach_terms(story_client, nft_collection): - - metadata = { - 'ip_metadata_uri': "test-uri", - 'ip_metadata_hash': web3.to_hex(web3.keccak(text="test-metadata-hash")), - 'nft_metadata_uri': "test-nft-uri", - 'nft_metadata_hash': web3.to_hex(web3.keccak(text="test-nft-metadata-hash")) - } - - response = story_client.IPAsset.mintAndRegisterIpAssetWithPilTerms( - spg_nft_contract=nft_collection, - terms=[{ - 'terms': { - 'transferable': True, - 'royalty_policy': ROYALTY_POLICY, - 'default_minting_fee': 1, - 'expiration': 0, - 'commercial_use': True, - 'commercial_attribution': False, - 'commercializer_checker': ZERO_ADDRESS, - 'commercializer_checker_data': ZERO_ADDRESS, - 'commercial_rev_share': 90, - 'commercial_rev_ceiling': 0, - 'derivatives_allowed': True, - 'derivatives_attribution': True, - 'derivatives_approval': False, - 'derivatives_reciprocal': True, - 'derivative_rev_ceiling': 0, - 'currency': MockERC20, - 'uri': "" - }, - 'licensing_config': { - 'is_set': True, - 'minting_fee': 1, - 'hook_data': "", - 'licensing_hook': ZERO_ADDRESS, - 'commercial_rev_share': 90, - 'disabled': False, - 'expect_minimum_group_reward_share': 0, - 'expect_group_reward_pool': ZERO_ADDRESS - } - }], - ip_metadata=metadata - ) - - assert 'txHash' in response - assert isinstance(response['txHash'], str) - - assert 'ipId' in response - assert isinstance(response['ipId'], str) - assert response['ipId'].startswith("0x") - - assert 'tokenId' in response - assert isinstance(response['tokenId'], int) - - assert 'licenseTermsIds' in response - assert isinstance(response['licenseTermsIds'], list) - assert all(isinstance(id, int) for id in response['licenseTermsIds']) - -# def test_register_attach(story_client, nft_collection): -# token_id = get_token_id_from_collection(nft_collection, story_client.web3, story_client.account) - -# pil_type = 'non_commercial_remix' -# metadata = { -# 'metadataURI': "test-uri", -# 'metadataHash': web3.to_hex(web3.keccak(text="test-metadata-hash")), -# 'nftMetadataHash': web3.to_hex(web3.keccak(text="test-nft-metadata-hash")) -# } -# deadline = getBlockTimestamp(web3) + 1000 -# response = story_client.IPAsset.registerIpAndAttachPilTerms( -# nft_contract=nft_collection, -# token_id=token_id, -# license_terms_data=[ -# { -# 'terms': { -# 'transferable': True, -# 'royalty_policy': ZERO_ADDRESS, -# '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': True, -# 'derivatives_attribution': True, -# 'derivatives_approval': False, -# 'derivatives_reciprocal': True, -# 'derivative_rev_ceiling': 0, -# 'currency': MockERC20, -# 'uri': "" -# }, -# 'licensing_config': { -# 'is_set': True, -# 'minting_fee': 0, -# 'licensing_hook': ZERO_ADDRESS, -# 'hook_data': ZERO_ADDRESS, -# 'commercial_rev_share': 0, -# 'disabled': False, -# 'expect_minimum_group_reward_share': 0, -# 'expect_group_reward_pool': ZERO_ADDRESS -# } -# }, -# { -# 'terms': { -# 'transferable': True, -# 'royalty_policy': ROYALTY_POLICY, -# 'minting_fee': 10000, -# 'expiration': 1000, -# 'commercial_use': True, -# 'commercial_attribution': False, -# 'commercializer_checker': ZERO_ADDRESS, -# 'commercializer_checker_data': ZERO_ADDRESS, -# 'commercial_rev_share': 0, -# 'commercial_rev_ceiling': 0, -# 'derivatives_allowed': True, -# 'derivatives_attribution': True, -# 'derivatives_approval': False, -# 'derivatives_reciprocal': True, -# 'derivative_rev_ceiling': 0, -# 'currency': MockERC20, -# 'uri': "test case" -# }, -# 'licensing_config': { -# 'is_set': True, -# 'minting_fee': 10000, -# 'licensing_hook': ZERO_ADDRESS, -# 'hook_data': ZERO_ADDRESS, -# 'commercial_rev_share': 0, -# 'disabled': False, -# 'expect_minimum_group_reward_share': 0, -# 'expect_group_reward_pool': ZERO_ADDRESS -# } -# } -# ], -# ip_metadata=metadata, -# deadline=deadline -# ) - -# assert 'txHash' in response -# assert isinstance(response['txHash'], str) -# assert response['txHash'].startswith("0x") - -# assert 'ipId' in response -# assert isinstance(response['ipId'], str) -# assert response['ipId'].startswith("0x") - -# assert 'licenseTermsId' in response -# assert isinstance(response['licenseTermsId'], int) - -# def test_register_ip_derivative(story_client, nft_collection): -# child_token_id = get_token_id(nft_collection, story_client.web3, story_client.account) - -# pil_type = 'non_commercial_remix' -# mint_metadata = { -# 'metadataHash': web3.to_hex(web3.keccak(text="test-metadata-hash")), -# 'nftMetadataHash': web3.to_hex(web3.keccak(text="test-nft-metadata-hash")) -# } - -# mint_response = story_client.IPAsset.mintAndRegisterIpAssetWithPilTerms( -# nft_contract=nft_collection, -# pil_type=pil_type, -# metadata=mint_metadata, -# minting_fee=100, -# commercial_rev_share=10, -# currency=MockERC20 -# ) - -# parent_ip_id = mint_response['ipId'] -# license_terms_id = mint_response['licenseTermsId'] - -# metadata = { -# 'metadataURI': "test-uri", -# 'metadataHash': web3.to_hex(web3.keccak(text="test-metadata-hash")), -# } -# derivData = { -# 'parentIpIds': [parent_ip_id], -# 'licenseTermsIds': [license_terms_id] -# } - -# response = story_client.IPAsset.registerDerivativeIp( -# nft_contract=nft_collection, -# token_id=child_token_id, -# metadata=metadata, -# deadline=1000, -# deriv_data=derivData -# ) - -# assert 'txHash' in response -# assert isinstance(response['txHash'], str) -# assert response['txHash'].startswith("0x") - -# assert 'ipId' in response -# assert isinstance(response['ipId'], str) -# assert response['ipId'].startswith("0x") + @pytest.fixture(scope="module") + def non_commercial_license(self, story_client): + license_register_response = story_client.License.registerNonComSocialRemixingPIL() + no_commercial_license_terms_id = license_register_response['licenseTermsId'] + return no_commercial_license_terms_id + + @pytest.fixture(scope="module") + def parent_ip_id(self, story_client, non_commercial_license): + token_id = get_token_id(MockERC721, story_client.web3, story_client.account) + response = story_client.IPAsset.register( + nft_contract=MockERC721, + token_id=token_id + ) + + attach_license_response = story_client.License.attachLicenseTerms(response['ipId'], PIL_LICENSE_TEMPLATE, non_commercial_license) + + return response['ipId'] + + def test_register_derivative(self, story_client, child_ip_id, parent_ip_id, non_commercial_license): + response = story_client.IPAsset.registerDerivative( + child_ip_id=child_ip_id, + parent_ip_ids=[parent_ip_id], + license_terms_ids=[non_commercial_license], + max_minting_fee=0, + max_rts=5 * 10 ** 6, + max_revenue_share=0, + ) + + assert response is not None + assert 'txHash' in response + assert response['txHash'] is not None + assert isinstance(response['txHash'], str) + assert len(response['txHash']) > 0 + + def test_registerDerivativeWithLicenseTokens(self, story_client, parent_ip_id, non_commercial_license): + token_id = get_token_id(MockERC721, story_client.web3, story_client.account) + child_response = story_client.IPAsset.register( + nft_contract=MockERC721, + token_id=token_id + ) + child_ip_id = child_response['ipId'] + + license_token_response = story_client.License.mintLicenseTokens( + licensor_ip_id=parent_ip_id, + license_template=PIL_LICENSE_TEMPLATE, + license_terms_id=non_commercial_license, + amount=1, + receiver=account.address, + max_minting_fee=0, + max_revenue_share=1 + ) + licenseTokenIds = license_token_response['licenseTokenIds'] + + response = story_client.IPAsset.registerDerivativeWithLicenseTokens( + child_ip_id=child_ip_id, + license_token_ids=licenseTokenIds, + max_rts=5 * 10 ** 6 + ) + + assert response is not None + assert 'txHash' in response + assert response['txHash'] is not None + assert isinstance(response['txHash'], str) + assert len(response['txHash']) > 0 + +class TestIPAssetMinting: + @pytest.fixture(scope="module") + def nft_collection(self, story_client): + txData = story_client.NFTClient.createNFTCollection( + name="test-collection", + symbol="TEST", + max_supply=100, + is_public_minting=True, + mint_open=True, + contract_uri="test-uri", + mint_fee_recipient=account.address, + ) + return txData['nftContract'] + + def test_mint_register_attach_terms(self, story_client, nft_collection): + metadata = { + 'ip_metadata_uri': "test-uri", + 'ip_metadata_hash': web3.to_hex(web3.keccak(text="test-metadata-hash")), + 'nft_metadata_uri': "test-nft-uri", + 'nft_metadata_hash': web3.to_hex(web3.keccak(text="test-nft-metadata-hash")) + } + + response = story_client.IPAsset.mintAndRegisterIpAssetWithPilTerms( + spg_nft_contract=nft_collection, + terms=[{ + 'terms': { + 'transferable': True, + 'royalty_policy': ROYALTY_POLICY, + 'default_minting_fee': 1, + 'expiration': 0, + 'commercial_use': True, + 'commercial_attribution': False, + 'commercializer_checker': ZERO_ADDRESS, + 'commercializer_checker_data': ZERO_ADDRESS, + 'commercial_rev_share': 90, + 'commercial_rev_ceiling': 0, + 'derivatives_allowed': True, + 'derivatives_attribution': True, + 'derivatives_approval': False, + 'derivatives_reciprocal': True, + 'derivative_rev_ceiling': 0, + 'currency': MockERC20, + 'uri': "" + }, + 'licensing_config': { + 'is_set': True, + 'minting_fee': 1, + 'hook_data': "", + 'licensing_hook': ZERO_ADDRESS, + 'commercial_rev_share': 90, + 'disabled': False, + 'expect_minimum_group_reward_share': 0, + 'expect_group_reward_pool': ZERO_ADDRESS + } + }], + ip_metadata=metadata + ) + + assert 'txHash' in response + assert isinstance(response['txHash'], str) + + assert 'ipId' in response + assert isinstance(response['ipId'], str) + assert response['ipId'].startswith("0x") + + assert 'tokenId' in response + assert isinstance(response['tokenId'], int) + + assert 'licenseTermsIds' in response + assert isinstance(response['licenseTermsIds'], list) + assert all(isinstance(id, int) for id in response['licenseTermsIds']) + + def test_mint_register_ip(self, story_client, nft_collection): + metadata = { + 'ip_metadata_uri': "test-uri", + 'ip_metadata_hash': web3.to_hex(web3.keccak(text="test-metadata-hash")), + 'nft_metadata_uri': "test-nft-uri", + 'nft_metadata_hash': web3.to_hex(web3.keccak(text="test-nft-metadata-hash")) + } + + response = story_client.IPAsset.mintAndRegisterIp( + spg_nft_contract=nft_collection, + ip_metadata=metadata + ) From 88ab97020f8fb786d7ef7c71d8a392e000d0ce5b Mon Sep 17 00:00:00 2001 From: Boris Polania Date: Fri, 14 Mar 2025 14:21:21 -0700 Subject: [PATCH 04/17] Update test_integration_ip_account.py (#43) IPAccount Permission function issue fixed --- tests/integration/test_integration_ip_account.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/integration/test_integration_ip_account.py b/tests/integration/test_integration_ip_account.py index 950721b..bcfcae1 100644 --- a/tests/integration/test_integration_ip_account.py +++ b/tests/integration/test_integration_ip_account.py @@ -43,7 +43,7 @@ def test_execute(self, story_client): ) data = story_client.IPAccount.access_controller_client.contract.encode_abi( - abi_element_identifier="setPermission", + abi_element_identifier="setTransientPermission", args=[response['ipId'], account.address, "0x89630Ccf23277417FBdfd3076C702F5248267e78", @@ -88,7 +88,7 @@ def test_execute_with_encoded_data(self, story_client): ip_id = register_response['ipId'] data = story_client.IPAccount.access_controller_client.contract.encode_abi( - abi_element_identifier="setPermission", + abi_element_identifier="setTransientPermission", args=[ ip_id, account.address, @@ -125,7 +125,7 @@ def test_executeWithSig(self, story_client): state = story_client.IPAccount.getIpAccountNonce(ipId) core_data = story_client.IPAccount.access_controller_client.contract.encode_abi( - abi_element_identifier="setPermission", + abi_element_identifier="setTransientPermission", args=[ ipId, account.address, @@ -217,7 +217,7 @@ def test_execute_with_sig_multiple_permissions(self, story_client): calls_data = [] for func_sig in function_signatures: data = story_client.IPAccount.access_controller_client.contract.encode_abi( - abi_element_identifier="setPermission", + abi_element_identifier="setTransientPermission", args=[ ip_id, account.address, From 2a06637f74ea4c2ef742ec62bf2d549190c73ff8 Mon Sep 17 00:00:00 2001 From: Andrew Chung <58543094+aandrewchung@users.noreply.github.com> Date: Mon, 17 Mar 2025 16:00:44 +0900 Subject: [PATCH 05/17] [UPDATE] Added owner() fn and updated readability for consistency (#44) * Added owner() read fn to IPAccount module * Updated helper fns and parameter comments * remove extra execute() --- .../abi/IPAccountImpl/IPAccountImpl_client.py | 5 + .../resources/IPAccount.py | 161 ++++++++---------- 2 files changed, 72 insertions(+), 94 deletions(-) diff --git a/src/story_protocol_python_sdk/abi/IPAccountImpl/IPAccountImpl_client.py b/src/story_protocol_python_sdk/abi/IPAccountImpl/IPAccountImpl_client.py index f569108..3d412be 100644 --- a/src/story_protocol_python_sdk/abi/IPAccountImpl/IPAccountImpl_client.py +++ b/src/story_protocol_python_sdk/abi/IPAccountImpl/IPAccountImpl_client.py @@ -35,6 +35,11 @@ def build_executeWithSig_transaction(self, to, value, data, signer, deadline, si return self.contract.functions.executeWithSig(to, value, data, signer, deadline, signature).build_transaction(tx_params) + def owner(self, ): + + return self.contract.functions.owner().call() + + def state(self, ): return self.contract.functions.state().call() diff --git a/src/story_protocol_python_sdk/resources/IPAccount.py b/src/story_protocol_python_sdk/resources/IPAccount.py index a286fa2..2179a60 100644 --- a/src/story_protocol_python_sdk/resources/IPAccount.py +++ b/src/story_protocol_python_sdk/resources/IPAccount.py @@ -12,12 +12,9 @@ class IPAccount: """A class to execute a transaction from the IP Account. - :param web3: An instance of Web3. - :type web3: Web3 - :param account: The Web3 account used to sign and send transactions. - :type account: Any - :param chain_id: The ID of the blockchain network. - :type chain_id: int + :param web3 Web3: An instance of Web3. + :param account Any: The Web3 account used to sign and send transactions. + :param chain_id int: The ID of the blockchain network. """ def __init__(self, web3: Web3, account, chain_id: int): @@ -32,10 +29,8 @@ def __init__(self, web3: Web3, account, chain_id: int): def getToken(self, ip_id: str) -> dict: """Retrieve token information associated with an IP account. - :param ip_id: The IP ID to query. - :type ip_id: str - :returns: Dictionary containing chainId, tokenContract, and tokenId. - :rtype: dict + :param ip_id str: The IP ID to query. + :returns dict: Dictionary containing chainId, tokenContract, and tokenId. :raises ValueError: If the IP ID is invalid. """ try: @@ -50,102 +45,66 @@ def getToken(self, ip_id: str) -> dict: except ValueError: # Catch ValueError from to_checksum_address raise ValueError(f"Invalid IP id address: {ip_id}") - def _execute_transaction(self, ip_id: str, to: str, build_transaction_method, *args, tx_options: dict = None) -> dict: - """Execute a transaction from the IP Account. + def _validate_transaction_params(self, ip_id: str, to: str): + """Validate transaction parameters. - Internal helper method that handles transaction execution and validation. - - :param ip_id: The IP ID to get IP account. - :type ip_id: str - :param to: The recipient of the transaction. - :type to: str - :param build_transaction_method: Method to build the transaction. - :type build_transaction_method: Callable - :param args: Arguments to pass to the build method. - :type args: tuple - :param tx_options: Optional transaction options. - :type tx_options: dict, optional - :returns: Dictionary containing transaction details. - :rtype: dict + :param ip_id str: The IP ID to get IP account. + :param to str: The recipient of the transaction. :raises ValueError: If recipient address is invalid or IP is not registered. - :raises Exception: For other transaction-related errors. """ - try: - if not self.web3.is_address(to): - raise ValueError(f"The recipient of the transaction {to} is not a valid address.") - - if not self._is_registered(ip_id): - raise ValueError(f"The IP id {ip_id} is not registered.") - - ip_account_client = IPAccountImplClient(self.web3, contract_address=ip_id) - - response = build_and_send_transaction( - self.web3, - self.account, - build_transaction_method, - *args, - tx_options=tx_options - ) - - return response - - except Exception as e: - raise e + if not self.web3.is_address(to): + raise ValueError(f"The recipient of the transaction {to} is not a valid address.") + + if not self._is_registered(ip_id): + raise ValueError(f"The IP id {ip_id} is not registered.") def execute(self, to: str, value: int, ip_id: str, data: str, tx_options: dict = None) -> dict: """Execute a transaction from the IP Account. - :param to: The recipient of the transaction. - :type to: str - :param value: The amount of Ether to send. - :type value: int - :param ip_id: The IP ID to get IP account. - :type ip_id: str - :param data: The data to send with the transaction. - :type data: str - :param tx_options: Optional transaction options. - :type tx_options: dict, optional - :returns: Dictionary containing the transaction hash. - :rtype: dict + :param to str: The recipient of the transaction. + :param value int: The amount of Ether to send. + :param ip_id str: The IP ID to get IP account. + :param data str: The data to send with the transaction. + :param tx_options dict: Optional transaction options. + :returns dict: Dictionary containing the transaction hash. """ + self._validate_transaction_params(ip_id, to) + ip_account_client = IPAccountImplClient(self.web3, contract_address=ip_id) - return self._execute_transaction( - ip_id, - to, + + response = build_and_send_transaction( + self.web3, + self.account, ip_account_client.build_execute_transaction, to, - value, + value, data, 0, tx_options=tx_options ) + return response + def executeWithSig(self, ip_id: str, to: str, data: str, signer: str, deadline: int, signature: str, value: int = 0, tx_options: dict = None) -> dict: """Execute a signed transaction from the IP Account. - :param ip_id: The IP ID to get IP account. - :type ip_id: str - :param to: The recipient of the transaction. - :type to: str - :param data: The data to send with the transaction. - :type data: str - :param signer: The signer of the transaction. - :type signer: str - :param deadline: The deadline of the transaction signature. - :type deadline: int - :param signature: The EIP-712 encoded transaction signature. - :type signature: str - :param value: Optional amount of Ether to send. - :type value: int, optional - :param tx_options: Optional transaction options. - :type tx_options: dict, optional - :returns: Dictionary containing the transaction hash. - :rtype: dict + :param ip_id str: The IP ID to get IP account. + :param to str: The recipient of the transaction. + :param data str: The data to send with the transaction. + :param signer str: The signer of the transaction. + :param deadline int: The deadline of the transaction signature. + :param signature str: The EIP-712 encoded transaction signature. + :param value int: Optional amount of Ether to send. + :param tx_options dict: Optional transaction options. + :returns dict: Dictionary containing the transaction hash. """ + self._validate_transaction_params(ip_id, to) + ip_account_client = IPAccountImplClient(self.web3, contract_address=ip_id) - return self._execute_transaction( - ip_id, - to, + + response = build_and_send_transaction( + self.web3, + self.account, ip_account_client.build_executeWithSig_transaction, to, value, @@ -155,14 +114,14 @@ def executeWithSig(self, ip_id: str, to: str, data: str, signer: str, deadline: signature, tx_options=tx_options ) + + return response def getIpAccountNonce(self, ip_id: str) -> bytes: """Get the IP Account's internal nonce for transaction ordering. - :param ip_id: The IP ID to query. - :type ip_id: str - :returns: The IP Account's internal nonce for transaction ordering. - :rtype: bytes + :param ip_id str: The IP ID to query. + :returns bytes: The IP Account's internal nonce for transaction ordering. :raises ValueError: If the IP ID is invalid. """ try: @@ -175,9 +134,23 @@ def getIpAccountNonce(self, ip_id: str) -> bytes: def _is_registered(self, ip_id: str) -> bool: """Check if an IP is registered. - :param ip_id: The IP ID to check. - :type ip_id: str - :returns: True if registered, False otherwise. - :rtype: bool + :param ip_id str: The IP ID to check. + :returns bool: True if registered, False otherwise. """ - return self.ip_asset_registry_client.isRegistered(ip_id) \ No newline at end of file + return self.ip_asset_registry_client.isRegistered(ip_id) + + def owner(self, ip_id: str) -> str: + """Get the owner of the IP Account. + + :param ip_id str: The IP ID to get IP account. + :returns str: The owner of the IP Account. + :raises ValueError: If the IP ID is invalid. + """ + try: + checksum_address = Web3.to_checksum_address(ip_id) + ip_account_client = IPAccountImplClient(self.web3, contract_address=checksum_address) + return ip_account_client.owner() + except ValueError: # Catch ValueError from to_checksum_address + raise ValueError(f"Invalid IP id address: {ip_id}") + except Exception as e: + raise e \ No newline at end of file From 0d478d1f22d466afba9d143c08094f254e3a460b Mon Sep 17 00:00:00 2001 From: Andrew Chung <58543094+aandrewchung@users.noreply.github.com> Date: Tue, 18 Mar 2025 03:46:37 +0900 Subject: [PATCH 06/17] [UPDATE] Royalty Module (#45) * Updated payroyaltyonbehalf and integration test * Updated integration test for payroyalty * updated claimableRevenue() * Added claimAllRevenue * Added mockerc20 client for transfer * Updated parse revenue token claimed fn --- .../abi/MockERC20/MockERC20_client.py | 37 + .../RoyaltyWorkflows_client.py | 8 + .../abi/jsons/MockERC20.json | 351 ++++++++++ .../resources/Royalty.py | 468 +++++++------ .../scripts/config.json | 14 +- tests/integration/setup_for_integration.py | 6 +- tests/integration/test_integration_royalty.py | 646 ++++++++++-------- tests/integration/utils.py | 17 + 8 files changed, 1039 insertions(+), 508 deletions(-) create mode 100644 src/story_protocol_python_sdk/abi/MockERC20/MockERC20_client.py create mode 100644 src/story_protocol_python_sdk/abi/jsons/MockERC20.json diff --git a/src/story_protocol_python_sdk/abi/MockERC20/MockERC20_client.py b/src/story_protocol_python_sdk/abi/MockERC20/MockERC20_client.py new file mode 100644 index 0000000..5b94d51 --- /dev/null +++ b/src/story_protocol_python_sdk/abi/MockERC20/MockERC20_client.py @@ -0,0 +1,37 @@ + +import json +import os +from web3 import Web3 + +class MockERC20Client: + 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'] == 'MockERC20': + contract_address = contract['contract_address'] + break + if not contract_address: + raise ValueError(f"Contract address for MockERC20 not found in config.json") + abi_path = os.path.join(os.path.dirname(__file__), '..', '..', 'abi', 'jsons', 'MockERC20.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 transfer(self, to, value): + + return self.contract.functions.transfer(to, value).transact() + + def build_transfer_transaction(self, to, value, tx_params): + return self.contract.functions.transfer(to, value).build_transaction(tx_params) + + + def balanceOf(self, account): + + return self.contract.functions.balanceOf(account).call() + + \ No newline at end of file diff --git a/src/story_protocol_python_sdk/abi/RoyaltyWorkflows/RoyaltyWorkflows_client.py b/src/story_protocol_python_sdk/abi/RoyaltyWorkflows/RoyaltyWorkflows_client.py index f4c6e2f..cc8ed76 100644 --- a/src/story_protocol_python_sdk/abi/RoyaltyWorkflows/RoyaltyWorkflows_client.py +++ b/src/story_protocol_python_sdk/abi/RoyaltyWorkflows/RoyaltyWorkflows_client.py @@ -21,4 +21,12 @@ def __init__(self, web3: Web3): 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 claimAllRevenue(self, ancestorIpId, claimer, childIpIds, royaltyPolicies, currencyTokens): + + return self.contract.functions.claimAllRevenue(ancestorIpId, claimer, childIpIds, royaltyPolicies, currencyTokens).transact() + + def build_claimAllRevenue_transaction(self, ancestorIpId, claimer, childIpIds, royaltyPolicies, currencyTokens, tx_params): + return self.contract.functions.claimAllRevenue(ancestorIpId, claimer, childIpIds, royaltyPolicies, currencyTokens).build_transaction(tx_params) + \ No newline at end of file diff --git a/src/story_protocol_python_sdk/abi/jsons/MockERC20.json b/src/story_protocol_python_sdk/abi/jsons/MockERC20.json new file mode 100644 index 0000000..db82bcf --- /dev/null +++ b/src/story_protocol_python_sdk/abi/jsons/MockERC20.json @@ -0,0 +1,351 @@ +[ + { + "inputs": [], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "allowance", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "needed", + "type": "uint256" + } + ], + "name": "ERC20InsufficientAllowance", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "sender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "balance", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "needed", + "type": "uint256" + } + ], + "name": "ERC20InsufficientBalance", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "approver", + "type": "address" + } + ], + "name": "ERC20InvalidApprover", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "receiver", + "type": "address" + } + ], + "name": "ERC20InvalidReceiver", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "sender", + "type": "address" + } + ], + "name": "ERC20InvalidSender", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + } + ], + "name": "ERC20InvalidSpender", + "type": "error" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "Approval", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "Transfer", + "type": "event" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "internalType": "address", + "name": "spender", + "type": "address" + } + ], + "name": "allowance", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "approve", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "balanceOf", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "burn", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "decimals", + "outputs": [ + { + "internalType": "uint8", + "name": "", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "mint", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "name", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "symbol", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "totalSupply", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "transfer", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "transferFrom", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + } + ] \ No newline at end of file diff --git a/src/story_protocol_python_sdk/resources/Royalty.py b/src/story_protocol_python_sdk/resources/Royalty.py index d31d8ef..b49226d 100644 --- a/src/story_protocol_python_sdk/resources/Royalty.py +++ b/src/story_protocol_python_sdk/resources/Royalty.py @@ -6,6 +6,10 @@ from story_protocol_python_sdk.abi.IpRoyaltyVaultImpl.IpRoyaltyVaultImpl_client import IpRoyaltyVaultImplClient from story_protocol_python_sdk.abi.RoyaltyPolicyLAP.RoyaltyPolicyLAP_client import RoyaltyPolicyLAPClient from story_protocol_python_sdk.abi.RoyaltyModule.RoyaltyModule_client import RoyaltyModuleClient +from story_protocol_python_sdk.abi.IpRoyaltyVaultImpl.IpRoyaltyVaultImpl_client import IpRoyaltyVaultImplClient +from story_protocol_python_sdk.abi.RoyaltyWorkflows.RoyaltyWorkflows_client import RoyaltyWorkflowsClient +from story_protocol_python_sdk.abi.IPAccountImpl.IPAccountImpl_client import IPAccountImplClient +from story_protocol_python_sdk.abi.MockERC20.MockERC20_client import MockERC20Client from story_protocol_python_sdk.utils.transaction_utils import build_and_send_transaction @@ -25,233 +29,253 @@ def __init__(self, web3: Web3, account, chain_id: int): self.ip_asset_registry_client = IPAssetRegistryClient(web3) self.royalty_policy_lap_client = RoyaltyPolicyLAPClient(web3) self.royalty_module_client = RoyaltyModuleClient(web3) - - # def collectRoyaltyTokens(self, parent_ip_id: str, child_ip_id: str, tx_options: dict = None) -> dict: - # """ - # Allows ancestors to claim the royalty tokens and any accrued revenue tokens. - - # :param parent_ip_id str: The IP ID of the ancestor to whom the royalty tokens belong. - # :param child_ip_id str: The derivative IP ID. - # :param tx_options dict: [Optional] The transaction options. - # :return dict: A dictionary with the transaction hash and the number of royalty tokens collected. - # """ - # try: - # is_registered = self.ip_asset_registry_client.isRegistered(parent_ip_id) - # if not is_registered: - # raise ValueError(f"The parent IP with id {parent_ip_id} is not registered.") - - # proxy_address = self._getRoyaltyVaultAddress(child_ip_id) - # ip_royalty_vault_client = IpRoyaltyVaultImplClient(self.web3, contract_address=proxy_address) - - # response = build_and_send_transaction( - # self.web3, - # self.account, - # ip_royalty_vault_client.build_collectRoyaltyTokens_transaction, - # parent_ip_id, - # tx_options=tx_options - # ) - - # royaltyTokensCollected = self._parseTxRoyaltyTokensCollectedEvent(response['txReceipt']) - - # return { - # 'txHash': response['txHash'], - # 'royaltyTokensCollected': royaltyTokensCollected - # } - - # except Exception as e: - # raise e - - # def _getRoyaltyVaultAddress(self, ip_id: str) -> str: - # """ - # Get the royalty vault address for a given IP ID. - - # :param ip_id str: The IP ID. - # :return str: The respective royalty vault address. - # """ - # is_registered = self.ip_asset_registry_client.isRegistered(ip_id) - # if not is_registered: - # raise ValueError(f"The IP with id {ip_id} is not registered.") - - # data = self.royalty_policy_lap_client.getRoyaltyData(ip_id) - - # if not data or not data[1] or data[1] == "0x": - # raise ValueError(f"The royalty vault IP with id {ip_id} address is not set.") - - # return data[1] - - # def _parseTxRoyaltyTokensCollectedEvent(self, tx_receipt: dict) -> int: - # """ - # Parse the RoyaltyTokensCollected event from a transaction receipt. - - # :param tx_receipt dict: The transaction receipt. - # :return int: The number of royalty tokens collected. - # """ - # event_signature = self.web3.keccak(text="RoyaltyTokensCollected(address,uint256)").hex() - - # for log in tx_receipt['logs']: - # if log['topics'][0].hex() == event_signature: - # data = log['data'] - - # royalty_tokens_collected = int.from_bytes(data[-32:], byteorder='big') - # return royalty_tokens_collected - - # return None - - # def snapshot(self, child_ip_id: str, tx_options: dict = None) -> dict: - # """ - # Snapshots the claimable revenue and royalty token amounts. - - # :param child_ip_id str: The child IP ID. - # :param tx_options dict: [Optional] The transaction options. - # :return dict: A dictionary with the transaction hash and the snapshot ID. - # """ - # try: - # proxy_address = self._getRoyaltyVaultAddress(child_ip_id) - # ip_royalty_vault_client = IpRoyaltyVaultImplClient(self.web3, contract_address=proxy_address) - - # response = build_and_send_transaction( - # self.web3, - # self.account, - # ip_royalty_vault_client.build_snapshot_transaction, - # tx_options=tx_options - # ) - - # snapshotId = self._parseTxSnapshotCompletedEvent(response['txReceipt']) - - # return { - # 'txHash': response['txHash'], - # 'snapshotId': snapshotId - # } - # except Exception as e: - # raise e - - # def _parseTxSnapshotCompletedEvent(self, tx_receipt: dict) -> int: - # """ - # Parse the SnapshotCompleted event from a transaction receipt. - - # :param tx_receipt dict: The transaction receipt. - # :return int: The snapshot ID. - # """ - # event_signature = self.web3.keccak(text="SnapshotCompleted(uint256,uint256,uint32)").hex() + self.ip_royalty_vault_client = IpRoyaltyVaultImplClient(web3) + self.royalty_workflows_client = RoyaltyWorkflowsClient(web3) + self.ip_account_impl_client = IPAccountImplClient(web3) + self.mock_erc20_client = MockERC20Client(web3) + + def getRoyaltyVaultAddress(self, ip_id: str) -> str: + """ + Get the royalty vault address for a given IP ID. + + :param ip_id str: The IP ID. + :return str: The respective royalty vault address. + """ + is_registered = self.ip_asset_registry_client.isRegistered(ip_id) + if not is_registered: + raise ValueError(f"The IP with id {ip_id} is not registered.") + + return self.royalty_module_client.ipRoyaltyVaults(ip_id) + + def claimableRevenue(self, royalty_vault_ip_id: str, claimer: str, token: str) -> int: + """ + Calculates the amount of revenue token claimable by a token holder. + + :param royalty_vault_ip_id str: The id of the royalty vault. + :param claimer str: The address of the royalty token holder. + :param token str: The revenue token to claim. + :return int: The claimable revenue amount. + """ + try: + proxy_address = self.getRoyaltyVaultAddress(royalty_vault_ip_id) + ip_royalty_vault_client = IpRoyaltyVaultImplClient(self.web3, contract_address=proxy_address) + + claimable_revenue = ip_royalty_vault_client.claimableRevenue( + claimer=claimer, + token=token + ) + + return claimable_revenue + + except Exception as e: + raise e - # for log in tx_receipt['logs']: - # if log['topics'][0].hex() == event_signature: - # data = log['data'] + def payRoyaltyOnBehalf(self, receiver_ip_id: str, payer_ip_id: str, token: str, amount: int, tx_options: dict = None) -> dict: + """ + Allows the function caller to pay royalties to the receiver IP asset on behalf of the payer IP asset. + + :param receiver_ip_id str: The IP ID that receives the royalties. + :param payer_ip_id str: The ID of the IP asset that pays the royalties. + :param token str: The token to use to pay the royalties. + :param amount int: The amount to pay. + :param tx_options dict: [Optional] The transaction options. + :return dict: A dictionary with the transaction hash. + """ + try: + is_receiver_registered = self.ip_asset_registry_client.isRegistered(receiver_ip_id) + if not is_receiver_registered: + raise ValueError(f"The receiver IP with id {receiver_ip_id} is not registered.") + + is_payer_registered = self.ip_asset_registry_client.isRegistered(payer_ip_id) + if not is_payer_registered: + raise ValueError(f"The payer IP with id {payer_ip_id} is not registered.") - # snapshotId = int.from_bytes(data[:32], byteorder='big') - - # return snapshotId - - # return None - - # def claimableRevenue(self, child_ip_id: str, account_address: str, snapshot_id: int, token: str) -> int: - # """ - # Calculates the amount of revenue token claimable by a token holder at certain snapshot. - - # :param child_ip_id str: The child IP ID. - # :param account_address str: The address of the token holder. - # :param snapshot_id int: The snapshot ID. - # :param token str: The revenue token to claim. - # :return int: The claimable revenue amount. - # """ - # try: - # proxy_address = self._getRoyaltyVaultAddress(child_ip_id) - # ip_royalty_vault_client = IpRoyaltyVaultImplClient(self.web3, contract_address=proxy_address) - - # claimable_revenue = ip_royalty_vault_client.claimableRevenue( - # account=account_address, - # snapshotId=snapshot_id, - # token=token - # ) - - # return claimable_revenue - - # except Exception as e: - # raise e + response = build_and_send_transaction( + self.web3, + self.account, + self.royalty_module_client.build_payRoyaltyOnBehalf_transaction, + receiver_ip_id, + payer_ip_id, + token, + amount, + tx_options=tx_options + ) + + return {'txHash': response['txHash']} - # def payRoyaltyOnBehalf(self, receiver_ip_id: str, payer_ip_id: str, token: str, amount: int, tx_options: dict = None) -> dict: - # """ - # Allows the function caller to pay royalties to the receiver IP asset on behalf of the payer IP asset. - - # :param receiver_ip_id str: The IP ID that receives the royalties. - # :param payer_ip_id str: The ID of the IP asset that pays the royalties. - # :param token str: The token to use to pay the royalties. - # :param amount int: The amount to pay. - # :param tx_options dict: [Optional] The transaction options. - # :return dict: A dictionary with the transaction hash. - # """ - # try: - # is_receiver_registered = self.ip_asset_registry_client.isRegistered(receiver_ip_id) - # if not is_receiver_registered: - # raise ValueError(f"The receiver IP with id {receiver_ip_id} is not registered.") - - # is_payer_registered = self.ip_asset_registry_client.isRegistered(payer_ip_id) - # if not is_payer_registered: - # raise ValueError(f"The payer IP with id {payer_ip_id} is not registered.") - - # response = build_and_send_transaction( - # self.web3, - # self.account, - # self.royalty_module_client.build_payRoyaltyOnBehalf_transaction, - # receiver_ip_id, - # payer_ip_id, - # token, - # amount, - # tx_options=tx_options - # ) - - # return {'txHash': response['txHash']} - - # except Exception as e: - # raise e + except Exception as e: + raise e - # def claimRevenue(self, snapshot_ids: list, child_ip_id: str, token: str, tx_options: dict = None) -> dict: - # """ - # Allows token holders to claim by a list of snapshot IDs based on the token balance at certain snapshot. - - # :param snapshot_ids list: The list of snapshot IDs. - # :param child_ip_id str: The child IP ID. - # :param token str: The revenue token to claim. - # :param tx_options dict: [Optional] The transaction options. - # :return dict: A dictionary with the transaction hash and the number of claimable tokens. - # """ - # try: - # proxy_address = self._getRoyaltyVaultAddress(child_ip_id) - # ip_royalty_vault_client = IpRoyaltyVaultImplClient(self.web3, contract_address=proxy_address) - - # response = build_and_send_transaction( - # self.web3, - # self.account, - # ip_royalty_vault_client.build_claimRevenueBySnapshotBatch_transaction, - # snapshot_ids, - # token, - # tx_options=tx_options - # ) - - # revenue_tokens_claimed = self._parseTxRevenueTokenClaimedEvent(response['txReceipt']) - - # return { - # 'txHash': response['txHash'], - # 'claimableToken': revenue_tokens_claimed - # } + def claimAllRevenue(self, ancestor_ip_id: str, claimer: str, child_ip_ids: list, royalty_policies: list, currency_tokens: list, claim_options: dict = None, tx_options: dict = 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. + """ + 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, + tx_options=tx_options + ) + + tx_hashes = [response['txHash']] + + # 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 ownsClaimer 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['txReceipt'], + 'txHashes': tx_hashes + } + + claimed_tokens = self._parseTxRevenueTokenClaimedEvent(response['txReceipt']) + + auto_transfer = claim_options.get('autoTransferAllClaimedTokensFromIp', False) if claim_options else False + # auto_unwrap = claim_options['autoUnwrapIpTokens'] + + print("ip account: ", ip_account) + print("type of ip account: ", type(ip_account)) + print("is_claimer_ip: ", is_claimer_ip) + print("owns_claimer: ", owns_claimer) + print("ip account owner: ", ip_account.owner()) + print("claimer: ", claimer) + + # transfer claimed tokens from IP to wallet if wallet owns IP + if auto_transfer and is_claimer_ip and owns_claimer: + hashes = self._transferClaimedTokensFromIpToWallet( + ancestor_ip_id, + ip_account, + claimed_tokens + ) + tx_hashes.extend(hashes) + + return { + 'receipt': response['txReceipt'], + 'claimedTokens': claimed_tokens, + 'txHashes': tx_hashes + } + + except Exception as e: + raise ValueError(f"Failed to claim all revenue: {str(e)}") - # except Exception as e: - # raise e + def _get_claimer_info(self, claimer): + """ + Get information about the claimer address. - # def _parseTxRevenueTokenClaimedEvent(self, tx_receipt: dict) -> int: - # """ - # Parse the RevenueTokenClaimed event from a transaction receipt. - - # :param tx_receipt dict: The transaction receipt. - # :return int: The number of revenue tokens claimed. - # """ - # event_signature = self.web3.keccak(text="RevenueTokenClaimed(address,address,uint256)").hex() + :param claimer str: The claimer address to check + :return dict: Dictionary containing: + - owns_claimer (bool): Whether the wallet owns the claimer + - is_claimer_ip (bool): Whether the claimer is an IP + - ip_account (IpAccountImplClient): IP account client if claimer is an IP + """ + print("the claimer is ", claimer ) + is_claimer_ip = self.ip_asset_registry_client.isRegistered(claimer) + print("is_claimer_ip: ", is_claimer_ip) + owns_claimer = claimer == self.account.address + print("owns_claimer: ", owns_claimer) + + ip_account = None + if is_claimer_ip: + ip_account = IPAccountImplClient(self.web3, contract_address=claimer) + ip_owner = ip_account.owner() + owns_claimer = ip_owner == self.account.address + + return owns_claimer, is_claimer_ip, ip_account + + def _transferClaimedTokensFromIpToWallet(self, ancestor_ip_id: str, ip_account, claimed_tokens: list) -> list: + """ + Transfer claimed tokens from an IP account to the wallet. + + :param ancestor_ip_id str: The IP ID of the ancestor. + :param ip_account IpAccountImplClient: The IP account to transfer from + :param claimed_tokens list: List of claimed tokens, each containing token address and amount + :return list: List of transaction hashes + """ + tx_hashes = [] + + for claimed_token in claimed_tokens: + token = claimed_token['token'] + amount = claimed_token['amount'] + + if amount <= 0: + continue + + # Build ERC20 transfer function data + transfer_data = self.mock_erc20_client.contract.encode_abi( + abi_element_identifier="transfer", + args=[self.account.address, amount] + ) + + print("transfer data: ", transfer_data) + print("token: ", token) + + # Execute transfer through IP account + tx_hash = ip_account.execute( + self.web3.to_checksum_address(token), + 0, + transfer_data, + 0 + ) + tx_hashes.append(tx_hash) + + return tx_hashes + + def _parseTxRevenueTokenClaimedEvent(self, tx_receipt: dict) -> list: + """ + Parse the RevenueTokenClaimed events from a transaction receipt. + + :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(text="RevenueTokenClaimed(address,address,uint256)").hex() + claimed_tokens = [] - # for log in tx_receipt['logs']: - # if log['topics'][0].hex() == event_signature: - # data = log['data'] - - # revenue_tokens_claimed = int.from_bytes(data[-32:], byteorder='big') - # return revenue_tokens_claimed + 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 + }) - # return None + return claimed_tokens diff --git a/src/story_protocol_python_sdk/scripts/config.json b/src/story_protocol_python_sdk/scripts/config.json index eba3c35..6e15035 100644 --- a/src/story_protocol_python_sdk/scripts/config.json +++ b/src/story_protocol_python_sdk/scripts/config.json @@ -31,7 +31,8 @@ "execute", "executeWithSig", "state", - "token" + "token", + "owner" ] }, { @@ -166,7 +167,8 @@ "transferToVaultAndSnapshotAndClaimByTokenBatch", "transferToVaultAndSnapshotAndClaimBySnapshotBatch", "snapshotAndClaimByTokenBatch", - "snapshotAndClaimBySnapshotBatch" + "snapshotAndClaimBySnapshotBatch", + "claimAllRevenue" ] }, { @@ -208,6 +210,14 @@ "contract_name": "RoyaltyPolicyLRP", "contract_address": "0x9156e603C949481883B1d3355c6f1132D191fC41", "functions": [] + }, + { + "contract_name": "MockERC20", + "contract_address": "0xF2104833d386a2734a4eB3B8ad6FC6812F29E38E", + "functions": [ + "transfer", + "balanceOf" + ] } ] } diff --git a/tests/integration/setup_for_integration.py b/tests/integration/setup_for_integration.py index 201ed93..d3af8ae 100644 --- a/tests/integration/setup_for_integration.py +++ b/tests/integration/setup_for_integration.py @@ -23,7 +23,8 @@ ZERO_ADDRESS, ROYALTY_POLICY, ROYALTY_MODULE, - PIL_LICENSE_TEMPLATE + PIL_LICENSE_TEMPLATE, + setup_royalty_vault ) # Load environment variables @@ -63,5 +64,6 @@ def story_client(): 'ZERO_ADDRESS', 'ROYALTY_POLICY', 'ROYALTY_MODULE', - 'PIL_LICENSE_TEMPLATE' + 'PIL_LICENSE_TEMPLATE', + 'setup_royalty_vault' ] \ No newline at end of file diff --git a/tests/integration/test_integration_royalty.py b/tests/integration/test_integration_royalty.py index c446242..57b7266 100644 --- a/tests/integration/test_integration_royalty.py +++ b/tests/integration/test_integration_royalty.py @@ -1,302 +1,384 @@ -# tests/integration/test_integration_royalty.py - -import os -import sys import pytest -from dotenv import load_dotenv from web3 import Web3 - -# Ensure the src directory is in the Python path -current_dir = os.path.dirname(__file__) -src_path = os.path.abspath(os.path.join(current_dir, '..', '..')) -if src_path not in sys.path: - sys.path.append(src_path) - -from utils import get_story_client_in_devnet, mint_tokens, approve, MockERC721, get_token_id, MockERC20 - -load_dotenv() -private_key = os.getenv('WALLET_PRIVATE_KEY') -rpc_url = os.getenv('RPC_PROVIDER_URL') - -# Initialize Web3 -web3 = Web3(Web3.HTTPProvider(rpc_url)) -if not web3.is_connected(): - raise Exception("Failed to connect to Web3 provider") - -# Set up the account with the private key -account = web3.eth.account.from_key(private_key) - -@pytest.fixture(scope="module") -def story_client(): - return get_story_client_in_devnet(web3, account) - -@pytest.mark.skip(reason="Permission Tests not implemented yet") -class TestRoyalty: - @pytest.fixture(scope="module") - def parent_ip_id(story_client): - token_id = get_token_id(MockERC721, story_client.web3, story_client.account) - - parent_ip_response = story_client.IPAsset.register( - token_contract=MockERC721, - token_id=token_id - ) - - parent_ip_id = parent_ip_response['ipId'] - - return parent_ip_id - - @pytest.fixture(scope="module") - def child_ip_id(story_client): - token_id = get_token_id(MockERC721, story_client.web3, story_client.account) - - response = story_client.IPAsset.register( - token_contract=MockERC721, - token_id=token_id - ) - - return response['ipId'] - +import copy + +from setup_for_integration import ( + web3, + account, + story_client, + get_token_id, + mint_tokens, + approve, + getBlockTimestamp, + check_event_in_tx, + MockERC721, + MockERC20, + ZERO_ADDRESS, + ROYALTY_POLICY, + PIL_LICENSE_TEMPLATE, + setup_royalty_vault +) + +# class TestRoyalty: +# @pytest.fixture(scope="module") +# def parent_ip_id(self, story_client): +# token_id = get_token_id(MockERC721, story_client.web3, story_client.account) + +# parent_ip_response = story_client.IPAsset.register( +# nft_contract=MockERC721, +# token_id=token_id +# ) + +# parent_ip_id = parent_ip_response['ipId'] + +# return parent_ip_id + +# @pytest.fixture(scope="module") +# def child_ip_id(self, story_client): +# token_id = get_token_id(MockERC721, story_client.web3, story_client.account) + +# response = story_client.IPAsset.register( +# nft_contract=MockERC721, +# token_id=token_id +# ) + +# return response['ipId'] + +# @pytest.fixture(scope="module") +# def attach_and_register(self, story_client, parent_ip_id, child_ip_id): +# license_terms_response = story_client.License.registerCommercialRemixPIL( +# default_minting_fee=1, +# currency=MockERC20, +# commercial_rev_share=10, +# royalty_policy=ROYALTY_POLICY +# ) + +# attach_license_response = story_client.License.attachLicenseTerms( +# ip_id=parent_ip_id, +# license_template=PIL_LICENSE_TEMPLATE, +# license_terms_id=license_terms_response['licenseTermsId'] +# ) + +# derivative_response = story_client.IPAsset.registerDerivative( +# child_ip_id=child_ip_id, +# parent_ip_ids=[parent_ip_id], +# license_terms_ids=[license_terms_response['licenseTermsId']], +# max_minting_fee=0, +# max_rts=0, +# max_revenue_share=0, +# ) + +# setup_royalty_vault(story_client, parent_ip_id, account) + +# def test_pay_royalty_on_behalf(self, story_client, parent_ip_id, child_ip_id, attach_and_register): +# response = story_client.Royalty.payRoyaltyOnBehalf( +# receiver_ip_id=parent_ip_id, +# payer_ip_id=child_ip_id, +# token=MockERC20, +# amount=1000 +# ) + +# assert response is not None +# assert response['txHash'] is not None + +# def test_claimable_revenue(self, story_client, parent_ip_id, child_ip_id, attach_and_register): +# response = story_client.Royalty.claimableRevenue( +# royalty_vault_ip_id=parent_ip_id, +# claimer=account.address, +# token=MockERC20 +# ) + +# assert response is not None +# assert type(response) == int +# assert response > 0 + +class TestClaimAllRevenue: @pytest.fixture(scope="module") - def attach_and_register(story_client, parent_ip_id, child_ip_id): - license_terms_response = story_client.License.registerCommercialRemixPIL( - minting_fee=1, - currency=MockERC20, - commercial_rev_share=100, - royalty_policy="0xAAbaf349C7a2A84564F9CC4Ac130B3f19A718E86" - ) - - attach_license_response = story_client.License.attachLicenseTerms( - ip_id=parent_ip_id, - license_template="0x260B6CB6284c89dbE660c0004233f7bB99B5edE7", - license_terms_id=license_terms_response['licenseTermsId'] - ) - - derivative_response = story_client.IPAsset.registerDerivative( - child_ip_id=child_ip_id, - parent_ip_ids=[parent_ip_id], - license_terms_ids=[license_terms_response['licenseTermsId']], - license_template="0x260B6CB6284c89dbE660c0004233f7bB99B5edE7" - ) - - def test_collectRoyaltyTokens(story_client, parent_ip_id, child_ip_id, attach_and_register): - response = story_client.Royalty.collectRoyaltyTokens( - parent_ip_id=parent_ip_id, - child_ip_id=child_ip_id - ) + # def setup_claim_all_revenue(self, story_client): + # # Create NFT collection + # collection_response = story_client.NFTClient.createNFTCollection( + # 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['nftContract'] + + # # 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")) + # } - assert response is not None - - assert 'txHash' in response - assert response['txHash'] is not None - assert isinstance(response['txHash'], str) - assert len(response['txHash']) > 0 - - assert 'royaltyTokensCollected' in response - assert response['royaltyTokensCollected'] is not None - assert isinstance(response['royaltyTokensCollected'], int) - - @pytest.fixture(scope="module") - def snapshot_id(story_client, child_ip_id): - response = story_client.Royalty.snapshot( - child_ip_id=child_ip_id - ) - - assert response is not None - assert 'txHash' in response - assert response['txHash'] is not None - assert isinstance(response['txHash'], str) - assert len(response['txHash']) > 0 - - assert 'snapshotId' in response - assert response['snapshotId'] is not None - assert isinstance(response['snapshotId'], int) - assert response['snapshotId'] >= 0 - - return response['snapshotId'] - - def test_snapshot(story_client, snapshot_id): - assert snapshot_id is not None - - def test_claimableRevenue(story_client, child_ip_id, snapshot_id): - response = story_client.Royalty.claimableRevenue( - child_ip_id=child_ip_id, - account_address=account.address, - snapshot_id=snapshot_id, - token=MockERC20 + # 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.mintAndRegisterIpAssetWithPilTerms( + # spg_nft_contract=spg_nft_contract, + # terms=copy.deepcopy(license_terms_template), + # ip_metadata=metadata_a + # ) + # ip_a = ip_a_response['ipId'] + # license_terms_id = ip_a_response['licenseTermsIds'][0] + + # # Register IP B as derivative of A + # ip_b_response = story_client.IPAsset.mintAndRegisterIp( + # spg_nft_contract=spg_nft_contract, + # ip_metadata=metadata_b + # ) + # ip_b = ip_b_response['ipId'] + # ip_b_derivative_response = story_client.IPAsset.registerDerivative( + # 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.mintAndRegisterIp( + # spg_nft_contract=spg_nft_contract, + # ip_metadata=metadata_c + # ) + # ip_c = ip_c_response['ipId'] + # ip_c_derivative_response = story_client.IPAsset.registerDerivative( + # 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.mintAndRegisterIp( + # spg_nft_contract=spg_nft_contract, + # ip_metadata=metadata_d + # ) + # ip_d = ip_d_response['ipId'] + # ip_d_derivative_response = story_client.IPAsset.registerDerivative( + # 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(self, setup_claim_all_revenue, story_client): + # response = story_client.Royalty.claimAllRevenue( + # 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] + # ) + + # assert response is not None + # assert 'txHashes' in response + # assert isinstance(response['txHashes'], list) + # assert len(response['txHashes']) > 0 + # assert response['claimedTokens'][0]['amount'] == 120 + + def setup_claim_all_revenue_claim_options(self, story_client): + # Create NFT collection + collection_response = story_client.NFTClient.createNFTCollection( + 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['nftContract'] + + # 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 + } + }] - assert response is not None - assert isinstance(response, int) - assert response >= 0 - - def test_payRoyaltyOnBehalf(story_client, parent_ip_id, child_ip_id): - token_ids = mint_tokens( - erc20_contract_address=MockERC20, - web3=web3, - account=account, - to_address=account.address, - amount=100000 * 10 ** 6 - ) + # 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")) + } - receipt = approve( - erc20_contract_address=MockERC20, - web3=web3, - account=account, - spender_address="0xaabaf349c7a2a84564f9cc4ac130b3f19a718e86", - amount=100000 * 10 ** 6) - - response = story_client.Royalty.payRoyaltyOnBehalf( - receiver_ip_id=parent_ip_id, - payer_ip_id=child_ip_id, - token=MockERC20, - amount=10 + 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.mintAndRegisterIpAssetWithPilTerms( + spg_nft_contract=spg_nft_contract, + terms=copy.deepcopy(license_terms_template), + ip_metadata=metadata_a ) + ip_a = ip_a_response['ipId'] + license_terms_id = ip_a_response['licenseTermsIds'][0] - assert response is not None - assert 'txHash' in response - assert response['txHash'] is not None - assert isinstance(response['txHash'], str) - assert len(response['txHash']) > 0 - - def test_claimRevenue(story_client, child_ip_id, snapshot_id): - response = story_client.Royalty.claimRevenue( - snapshot_ids=[snapshot_id], - child_ip_id=child_ip_id, - token=MockERC20, + # Register IP B as derivative of A + ip_b_response = story_client.IPAsset.mintAndRegisterIp( + spg_nft_contract=spg_nft_contract, + ip_metadata=metadata_b + ) + ip_b = ip_b_response['ipId'] + ip_b_derivative_response = story_client.IPAsset.registerDerivative( + child_ip_id=ip_b, + parent_ip_ids=[ip_a], + license_terms_ids=[license_terms_id] ) - assert response is not None - assert 'txHash' in response - assert response['txHash'] is not None - assert isinstance(response['txHash'], str) - assert len(response['txHash']) > 0 - - assert 'claimableToken' in response - assert response['claimableToken'] is not None - assert isinstance(response['claimableToken'], int) - assert response['claimableToken'] >= 0 - - def test_snapshot_and_claim_by_token_batch(story_client, child_ip_id): - """Test taking a snapshot and claiming revenue by token batch.""" - # First approve tokens for royalty payments - token_amount = 100000 * 10 ** 6 - mint_tokens( - erc20_contract_address=MockERC20, - web3=web3, - account=account, - to_address=account.address, - amount=token_amount + # Register IP C as derivative of B + ip_c_response = story_client.IPAsset.mintAndRegisterIp( + spg_nft_contract=spg_nft_contract, + ip_metadata=metadata_c ) - approve( - erc20_contract_address=MockERC20, - web3=web3, - account=account, - spender_address="0xaabaf349c7a2a84564f9cc4ac130b3f19a718e86", - amount=token_amount + ip_c = ip_c_response['ipId'] + ip_c_derivative_response = story_client.IPAsset.registerDerivative( + child_ip_id=ip_c, + parent_ip_ids=[ip_b], + license_terms_ids=[license_terms_id] ) - currency_tokens = [MockERC20] - - response = story_client.Royalty.snapshotAndClaimByTokenBatch( - royalty_vault_ip_id=child_ip_id, - currency_tokens=currency_tokens + # Register IP D as derivative of C + ip_d_response = story_client.IPAsset.mintAndRegisterIp( + spg_nft_contract=spg_nft_contract, + ip_metadata=metadata_d ) - - assert response is not None - assert 'txHash' in response - assert isinstance(response['txHash'], str) - assert len(response['txHash']) > 0 - assert 'snapshotId' in response - assert isinstance(response['snapshotId'], int) - assert response['snapshotId'] >= 0 - assert 'amountsClaimed' in response - assert isinstance(response['amountsClaimed'], int) - - def test_snapshot_and_claim_by_snapshot_batch(story_client, child_ip_id, snapshot_id): - """Test taking a snapshot and claiming revenue by snapshot batch.""" - currency_tokens = [MockERC20] - unclaimed_snapshot_ids = [snapshot_id] - - response = story_client.Royalty.snapshotAndClaimBySnapshotBatch( - royalty_vault_ip_id=child_ip_id, - currency_tokens=currency_tokens, - unclaimed_snapshot_ids=unclaimed_snapshot_ids + ip_d = ip_d_response['ipId'] + ip_d_derivative_response = story_client.IPAsset.registerDerivative( + child_ip_id=ip_d, + parent_ip_ids=[ip_c], + license_terms_ids=[license_terms_id] ) - - assert response is not None - assert 'txHash' in response - assert isinstance(response['txHash'], str) - assert len(response['txHash']) > 0 - assert 'snapshotId' in response - assert isinstance(response['snapshotId'], int) - assert response['snapshotId'] >= 0 - assert 'amountsClaimed' in response - assert isinstance(response['amountsClaimed'], int) - - def test_transfer_to_vault_and_snapshot_and_claim_by_token_batch(story_client, parent_ip_id, child_ip_id): - """Test transferring to vault, taking snapshot, and claiming by token batch.""" - royalty_claim_details = [{ - 'child_ip_id': child_ip_id, - 'royalty_policy': "0xAAbaf349C7a2A84564F9CC4Ac130B3f19A718E86", - 'currency_token': MockERC20, - 'amount': 100 - }] - - response = story_client.Royalty.transferToVaultAndSnapshotAndClaimByTokenBatch( - ancestor_ip_id=parent_ip_id, - royalty_claim_details=royalty_claim_details + + 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): + response = story_client.Royalty.claimAllRevenue( + 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']], + royalty_policies=[ROYALTY_POLICY, ROYALTY_POLICY], + currency_tokens=[MockERC20, MockERC20], + claim_options={ + 'autoTransferAllClaimedTokensFromIp': True + } ) - assert response is not None - assert 'txHash' in response - assert isinstance(response['txHash'], str) - assert len(response['txHash']) > 0 - assert 'snapshotId' in response - assert isinstance(response['snapshotId'], int) - assert response['snapshotId'] >= 0 - assert 'amountsClaimed' in response - assert isinstance(response['amountsClaimed'], int) - - def test_transfer_to_vault_and_snapshot_and_claim_by_snapshot_batch(story_client, parent_ip_id, child_ip_id, snapshot_id): - """Test transferring to vault, taking snapshot, and claiming by snapshot batch.""" - royalty_claim_details = [{ - 'child_ip_id': child_ip_id, - 'royalty_policy': "0xAAbaf349C7a2A84564F9CC4Ac130B3f19A718E86", - 'currency_token': MockERC20, - 'amount': 100 - }] - unclaimed_snapshot_ids = [snapshot_id] - - response = story_client.Royalty.transferToVaultAndSnapshotAndClaimBySnapshotBatch( - ancestor_ip_id=parent_ip_id, - royalty_claim_details=royalty_claim_details, - unclaimed_snapshot_ids=unclaimed_snapshot_ids - ) + print('the response is', response) assert response is not None - assert 'txHash' in response - assert isinstance(response['txHash'], str) - assert len(response['txHash']) > 0 - assert 'snapshotId' in response - assert isinstance(response['snapshotId'], int) - assert response['snapshotId'] >= 0 - assert 'amountsClaimed' in response - assert isinstance(response['amountsClaimed'], int) - - def test_royalty_vault_address(story_client, child_ip_id): - """Test getting royalty vault address for an IP.""" - vault_address = story_client.Royalty.getRoyaltyVaultAddress(child_ip_id) - - assert vault_address is not None - assert isinstance(vault_address, str) - assert vault_address.startswith('0x') - assert len(vault_address) == 42 # Valid Ethereum address length - - def test_get_royalty_vault_address_unregistered_ip(story_client): - """Test getting royalty vault address for unregistered IP.""" - unregistered_ip = "0x1234567890123456789012345678901234567890" - - with pytest.raises(ValueError) as exc_info: - story_client.Royalty.getRoyaltyVaultAddress(unregistered_ip) - - assert "is not registered" in str(exc_info.value) \ No newline at end of file + assert 'txHashes' in response + assert isinstance(response['txHashes'], list) + assert len(response['txHashes']) > 0 + assert response['claimedTokens'][0]['amount'] == 120 \ No newline at end of file diff --git a/tests/integration/utils.py b/tests/integration/utils.py index 17e8138..4665ecd 100644 --- a/tests/integration/utils.py +++ b/tests/integration/utils.py @@ -179,3 +179,20 @@ def check_event_in_tx(web3, tx_hash: str, event_text: str) -> bool: return True return False + +def setup_royalty_vault(story_client, parent_ip_id, account): + parent_ip_royalty_address = story_client.Royalty.getRoyaltyVaultAddress(parent_ip_id) + + transfer_data = story_client.Royalty.ip_royalty_vault_client.contract.encode_abi( + abi_element_identifier="transfer", + args=[account.address, 10 * 10 ** 6] + ) + + response = story_client.IPAccount.execute( + to=parent_ip_royalty_address, + value=0, + ip_id=parent_ip_id, + data=transfer_data + ) + + return response \ No newline at end of file From fe220bae7100d43674b152ce798f1d58ea581cc4 Mon Sep 17 00:00:00 2001 From: Ze Date: Tue, 18 Mar 2025 20:30:25 -0700 Subject: [PATCH 07/17] chore(*): update code owner (#48) * chore(*): update codeowner * add Seb --- .github/CODEOWNERS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 8593ebe..6cee03b 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1 @@ -* @DonFungible @edisonz0718 @jacob-tucker @sebsadface @DracoLi @AndyBoWu +* @aandrewchung @bpolania @sebsadface @edisonz0718 @DracoLi From fd485c89ec30d8f8976e60405a3523a9c5752e98 Mon Sep 17 00:00:00 2001 From: Andrew Chung <58543094+aandrewchung@users.noreply.github.com> Date: Thu, 20 Mar 2025 02:16:20 +0900 Subject: [PATCH 08/17] [UPDATE] Update Royalty Module to POC 1.3 (#47) * Updated payroyaltyonbehalf and integration test * Updated integration test for payroyalty * updated claimableRevenue() * Added claimAllRevenue * Added mockerc20 client for transfer * Updated parse revenue token claimed fn * [FEATURE] Added mintAndRegisterIp() (#42) * Added mintandremintAndRegisterIp() and integration test * Updated ip asset integration test to use classes * Fixed missing child_ip bug for deriv integration test * push to remote branch * Fixed autotransfer for claimAllRevenue() --- .../resources/Royalty.py | 36 +- tests/integration/test_integration_royalty.py | 451 ++++++++++-------- 2 files changed, 255 insertions(+), 232 deletions(-) diff --git a/src/story_protocol_python_sdk/resources/Royalty.py b/src/story_protocol_python_sdk/resources/Royalty.py index b49226d..44a132d 100644 --- a/src/story_protocol_python_sdk/resources/Royalty.py +++ b/src/story_protocol_python_sdk/resources/Royalty.py @@ -160,16 +160,9 @@ def claimAllRevenue(self, ancestor_ip_id: str, claimer: str, child_ip_ids: list, claimed_tokens = self._parseTxRevenueTokenClaimedEvent(response['txReceipt']) - auto_transfer = claim_options.get('autoTransferAllClaimedTokensFromIp', False) if claim_options else False + auto_transfer = claim_options.get('autoTransferAllClaimedTokensFromIp', True) if claim_options else True # auto_unwrap = claim_options['autoUnwrapIpTokens'] - print("ip account: ", ip_account) - print("type of ip account: ", type(ip_account)) - print("is_claimer_ip: ", is_claimer_ip) - print("owns_claimer: ", owns_claimer) - print("ip account owner: ", ip_account.owner()) - print("claimer: ", claimer) - # transfer claimed tokens from IP to wallet if wallet owns IP if auto_transfer and is_claimer_ip and owns_claimer: hashes = self._transferClaimedTokensFromIpToWallet( @@ -198,11 +191,8 @@ def _get_claimer_info(self, claimer): - is_claimer_ip (bool): Whether the claimer is an IP - ip_account (IpAccountImplClient): IP account client if claimer is an IP """ - print("the claimer is ", claimer ) is_claimer_ip = self.ip_asset_registry_client.isRegistered(claimer) - print("is_claimer_ip: ", is_claimer_ip) owns_claimer = claimer == self.account.address - print("owns_claimer: ", owns_claimer) ip_account = None if is_claimer_ip: @@ -235,18 +225,18 @@ def _transferClaimedTokensFromIpToWallet(self, ancestor_ip_id: str, ip_account, abi_element_identifier="transfer", args=[self.account.address, amount] ) - - print("transfer data: ", transfer_data) - print("token: ", token) - - # Execute transfer through IP account - tx_hash = ip_account.execute( - self.web3.to_checksum_address(token), - 0, - transfer_data, - 0 - ) - tx_hashes.append(tx_hash) + + # 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['txHash']) return tx_hashes diff --git a/tests/integration/test_integration_royalty.py b/tests/integration/test_integration_royalty.py index 57b7266..f26bae4 100644 --- a/tests/integration/test_integration_royalty.py +++ b/tests/integration/test_integration_royalty.py @@ -19,223 +19,225 @@ setup_royalty_vault ) -# class TestRoyalty: -# @pytest.fixture(scope="module") -# def parent_ip_id(self, story_client): -# token_id = get_token_id(MockERC721, story_client.web3, story_client.account) - -# parent_ip_response = story_client.IPAsset.register( -# nft_contract=MockERC721, -# token_id=token_id -# ) - -# parent_ip_id = parent_ip_response['ipId'] - -# return parent_ip_id - -# @pytest.fixture(scope="module") -# def child_ip_id(self, story_client): -# token_id = get_token_id(MockERC721, story_client.web3, story_client.account) - -# response = story_client.IPAsset.register( -# nft_contract=MockERC721, -# token_id=token_id -# ) - -# return response['ipId'] - -# @pytest.fixture(scope="module") -# def attach_and_register(self, story_client, parent_ip_id, child_ip_id): -# license_terms_response = story_client.License.registerCommercialRemixPIL( -# default_minting_fee=1, -# currency=MockERC20, -# commercial_rev_share=10, -# royalty_policy=ROYALTY_POLICY -# ) - -# attach_license_response = story_client.License.attachLicenseTerms( -# ip_id=parent_ip_id, -# license_template=PIL_LICENSE_TEMPLATE, -# license_terms_id=license_terms_response['licenseTermsId'] -# ) - -# derivative_response = story_client.IPAsset.registerDerivative( -# child_ip_id=child_ip_id, -# parent_ip_ids=[parent_ip_id], -# license_terms_ids=[license_terms_response['licenseTermsId']], -# max_minting_fee=0, -# max_rts=0, -# max_revenue_share=0, -# ) - -# setup_royalty_vault(story_client, parent_ip_id, account) - -# def test_pay_royalty_on_behalf(self, story_client, parent_ip_id, child_ip_id, attach_and_register): -# response = story_client.Royalty.payRoyaltyOnBehalf( -# receiver_ip_id=parent_ip_id, -# payer_ip_id=child_ip_id, -# token=MockERC20, -# amount=1000 -# ) - -# assert response is not None -# assert response['txHash'] is not None - -# def test_claimable_revenue(self, story_client, parent_ip_id, child_ip_id, attach_and_register): -# response = story_client.Royalty.claimableRevenue( -# royalty_vault_ip_id=parent_ip_id, -# claimer=account.address, -# token=MockERC20 -# ) - -# assert response is not None -# assert type(response) == int -# assert response > 0 +class TestRoyalty: + @pytest.fixture(scope="module") + def parent_ip_id(self, story_client): + token_id = get_token_id(MockERC721, story_client.web3, story_client.account) + + parent_ip_response = story_client.IPAsset.register( + nft_contract=MockERC721, + token_id=token_id + ) + spg_nft_contract = collection_response['nftContract'] + + parent_ip_id = parent_ip_response['ipId'] + + return parent_ip_id + + @pytest.fixture(scope="module") + def child_ip_id(self, story_client): + token_id = get_token_id(MockERC721, story_client.web3, story_client.account) + + response = story_client.IPAsset.register( + nft_contract=MockERC721, + token_id=token_id + ) + + return response['ipId'] + + @pytest.fixture(scope="module") + def attach_and_register(self, story_client, parent_ip_id, child_ip_id): + license_terms_response = story_client.License.registerCommercialRemixPIL( + default_minting_fee=1, + currency=MockERC20, + commercial_rev_share=10, + royalty_policy=ROYALTY_POLICY + ) + + attach_license_response = story_client.License.attachLicenseTerms( + ip_id=parent_ip_id, + license_template=PIL_LICENSE_TEMPLATE, + license_terms_id=license_terms_response['licenseTermsId'] + ) + + derivative_response = story_client.IPAsset.registerDerivative( + child_ip_id=child_ip_id, + parent_ip_ids=[parent_ip_id], + license_terms_ids=[license_terms_response['licenseTermsId']], + max_minting_fee=0, + max_rts=0, + max_revenue_share=0, + ) + + setup_royalty_vault(story_client, parent_ip_id, account) + + def test_pay_royalty_on_behalf(self, story_client, parent_ip_id, child_ip_id, attach_and_register): + response = story_client.Royalty.payRoyaltyOnBehalf( + receiver_ip_id=parent_ip_id, + payer_ip_id=child_ip_id, + token=MockERC20, + amount=1000 + ) + + assert response is not None + assert response['txHash'] is not None + + def test_claimable_revenue(self, story_client, parent_ip_id, child_ip_id, attach_and_register): + response = story_client.Royalty.claimableRevenue( + royalty_vault_ip_id=parent_ip_id, + claimer=account.address, + token=MockERC20 + ) + + assert response is not None + assert type(response) == int + assert response > 0 class TestClaimAllRevenue: @pytest.fixture(scope="module") - # def setup_claim_all_revenue(self, story_client): - # # Create NFT collection - # collection_response = story_client.NFTClient.createNFTCollection( - # 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['nftContract'] - - # # 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")) - # } + def setup_claim_all_revenue(self, story_client): + # Create NFT collection + collection_response = story_client.NFTClient.createNFTCollection( + 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['nftContract'] + + # 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_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_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.mintAndRegisterIpAssetWithPilTerms( - # spg_nft_contract=spg_nft_contract, - # terms=copy.deepcopy(license_terms_template), - # ip_metadata=metadata_a - # ) - # ip_a = ip_a_response['ipId'] - # license_terms_id = ip_a_response['licenseTermsIds'][0] - - # # Register IP B as derivative of A - # ip_b_response = story_client.IPAsset.mintAndRegisterIp( - # spg_nft_contract=spg_nft_contract, - # ip_metadata=metadata_b - # ) - # ip_b = ip_b_response['ipId'] - # ip_b_derivative_response = story_client.IPAsset.registerDerivative( - # 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.mintAndRegisterIp( - # spg_nft_contract=spg_nft_contract, - # ip_metadata=metadata_c - # ) - # ip_c = ip_c_response['ipId'] - # ip_c_derivative_response = story_client.IPAsset.registerDerivative( - # 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.mintAndRegisterIp( - # spg_nft_contract=spg_nft_contract, - # ip_metadata=metadata_d - # ) - # ip_d = ip_d_response['ipId'] - # ip_d_derivative_response = story_client.IPAsset.registerDerivative( - # child_ip_id=ip_d, - # parent_ip_ids=[ip_c], - # license_terms_ids=[license_terms_id] - # ) + 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.mintAndRegisterIpAssetWithPilTerms( + spg_nft_contract=spg_nft_contract, + terms=copy.deepcopy(license_terms_template), + ip_metadata=metadata_a + ) + ip_a = ip_a_response['ipId'] + license_terms_id = ip_a_response['licenseTermsIds'][0] + + # Register IP B as derivative of A + ip_b_response = story_client.IPAsset.mintAndRegisterIp( + spg_nft_contract=spg_nft_contract, + ip_metadata=metadata_b + ) + ip_b = ip_b_response['ipId'] + ip_b_derivative_response = story_client.IPAsset.registerDerivative( + 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.mintAndRegisterIp( + spg_nft_contract=spg_nft_contract, + ip_metadata=metadata_c + ) + ip_c = ip_c_response['ipId'] + ip_c_derivative_response = story_client.IPAsset.registerDerivative( + 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.mintAndRegisterIp( + spg_nft_contract=spg_nft_contract, + ip_metadata=metadata_d + ) + ip_d = ip_d_response['ipId'] + ip_d_derivative_response = story_client.IPAsset.registerDerivative( + 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(self, setup_claim_all_revenue, story_client): - # response = story_client.Royalty.claimAllRevenue( - # 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] - # ) - - # assert response is not None - # assert 'txHashes' in response - # assert isinstance(response['txHashes'], list) - # assert len(response['txHashes']) > 0 - # assert response['claimedTokens'][0]['amount'] == 120 + return { + 'ip_a': ip_a, + 'ip_b': ip_b, + 'ip_c': ip_c, + 'ip_d': ip_d + } + + def test_claim_all_revenue(self, setup_claim_all_revenue, story_client): + response = story_client.Royalty.claimAllRevenue( + 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] + ) + assert response is not None + assert 'txHashes' in response + assert isinstance(response['txHashes'], list) + assert len(response['txHashes']) > 0 + assert response['claimedTokens'][0]['amount'] == 120 + + @pytest.fixture(scope="module") def setup_claim_all_revenue_claim_options(self, story_client): # Create NFT collection collection_response = story_client.NFTClient.createNFTCollection( @@ -344,6 +346,37 @@ def setup_claim_all_revenue_claim_options(self, story_client): license_terms_ids=[license_terms_id] ) + # Register IP D as derivative of C + ip_d_response = story_client.IPAsset.mintAndRegisterIp( + spg_nft_contract=spg_nft_contract, + ip_metadata=metadata_d + ) + ip_d = ip_d_response['ipId'] + ip_d_derivative_response = story_client.IPAsset.registerDerivative( + 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): + response = story_client.Royalty.claimAllRevenue( + 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']], + royalty_policies=[ROYALTY_POLICY, ROYALTY_POLICY], + currency_tokens=[MockERC20, MockERC20], + claim_options={ + 'autoTransferAllClaimedTokensFromIp': False + } + ) + # Register IP D as derivative of C ip_d_response = story_client.IPAsset.mintAndRegisterIp( spg_nft_contract=spg_nft_contract, From 47e774bae0f9feef71a4fb78564c2765b42c1da6 Mon Sep 17 00:00:00 2001 From: Boris Polania Date: Thu, 20 Mar 2025 09:45:09 -0700 Subject: [PATCH 09/17] Royalty Integration Tests (#46) * Fixes * Update test_integration_royalty.py * Fixes and code cleaning * Update pytest.ini * Update RoyaltyPolicyLRP.json * Update test_integration_royalty.py * Update tests * Update test_integration_royalty.py * Code cleaning and fixing * More cleaning and fixing * Update test_integration_royalty.py * Update setup_for_integration.py --- .../utils/license_terms.py | 2 +- tests/integration/setup_for_integration.py | 6 +- tests/integration/test_integration_royalty.py | 164 ++++++++++-------- tests/integration/utils.py | 1 + 4 files changed, 93 insertions(+), 80 deletions(-) diff --git a/src/story_protocol_python_sdk/utils/license_terms.py b/src/story_protocol_python_sdk/utils/license_terms.py index 0f6d7d5..6936aba 100644 --- a/src/story_protocol_python_sdk/utils/license_terms.py +++ b/src/story_protocol_python_sdk/utils/license_terms.py @@ -139,7 +139,7 @@ def validate_licensing_config(self, params): default_params = { 'is_set': False, 'minting_fee': 0, - 'hook_data': "", + 'hook_data': ZERO_ADDRESS, 'licensing_hook': ZERO_ADDRESS, 'commercial_rev_share': 0, 'disabled': False, diff --git a/tests/integration/setup_for_integration.py b/tests/integration/setup_for_integration.py index d3af8ae..277a839 100644 --- a/tests/integration/setup_for_integration.py +++ b/tests/integration/setup_for_integration.py @@ -24,6 +24,7 @@ ROYALTY_POLICY, ROYALTY_MODULE, PIL_LICENSE_TEMPLATE, + WIP_TOKEN_ADDRESS, setup_royalty_vault ) @@ -62,8 +63,9 @@ def story_client(): 'MockERC721', 'MockERC20', 'ZERO_ADDRESS', - 'ROYALTY_POLICY', + 'ROYALTY_POLICY' 'ROYALTY_MODULE', 'PIL_LICENSE_TEMPLATE', - 'setup_royalty_vault' + 'setup_royalty_vault', + 'WIP_TOKEN_ADDRESS' ] \ No newline at end of file diff --git a/tests/integration/test_integration_royalty.py b/tests/integration/test_integration_royalty.py index f26bae4..8e14eca 100644 --- a/tests/integration/test_integration_royalty.py +++ b/tests/integration/test_integration_royalty.py @@ -9,89 +9,131 @@ get_token_id, mint_tokens, approve, - getBlockTimestamp, - check_event_in_tx, MockERC721, MockERC20, ZERO_ADDRESS, ROYALTY_POLICY, + ROYALTY_MODULE, PIL_LICENSE_TEMPLATE, - setup_royalty_vault ) class TestRoyalty: - @pytest.fixture(scope="module") - def parent_ip_id(self, story_client): - token_id = get_token_id(MockERC721, story_client.web3, story_client.account) + @pytest.fixture(scope="module") + def setup_ips_and_licenses(self, story_client): + """Setup parent and child IPs with proper license relationships""" + + parent_token_id = get_token_id(MockERC721, story_client.web3, story_client.account) parent_ip_response = story_client.IPAsset.register( nft_contract=MockERC721, - token_id=token_id + token_id=parent_token_id ) - spg_nft_contract = collection_response['nftContract'] - parent_ip_id = parent_ip_response['ipId'] - - return parent_ip_id - - @pytest.fixture(scope="module") - def child_ip_id(self, story_client): - token_id = get_token_id(MockERC721, story_client.web3, story_client.account) - - response = story_client.IPAsset.register( + + child_token_id = get_token_id(MockERC721, story_client.web3, story_client.account) + child_ip_response = story_client.IPAsset.register( nft_contract=MockERC721, - token_id=token_id + token_id=child_token_id ) - - return response['ipId'] - - @pytest.fixture(scope="module") - def attach_and_register(self, story_client, parent_ip_id, child_ip_id): + child_ip_id = child_ip_response['ipId'] + license_terms_response = story_client.License.registerCommercialRemixPIL( - default_minting_fee=1, + default_minting_fee=100000, currency=MockERC20, commercial_rev_share=10, royalty_policy=ROYALTY_POLICY ) - - attach_license_response = story_client.License.attachLicenseTerms( + license_terms_id = license_terms_response['licenseTermsId'] + + story_client.License.attachLicenseTerms( ip_id=parent_ip_id, license_template=PIL_LICENSE_TEMPLATE, - license_terms_id=license_terms_response['licenseTermsId'] + license_terms_id=license_terms_id ) - - derivative_response = story_client.IPAsset.registerDerivative( + + story_client.IPAsset.registerDerivative( child_ip_id=child_ip_id, parent_ip_ids=[parent_ip_id], - license_terms_ids=[license_terms_response['licenseTermsId']], + license_terms_ids=[license_terms_id], max_minting_fee=0, max_rts=0, max_revenue_share=0, ) + + mint_tokens( + erc20_contract_address=MockERC20, + web3=web3, + account=account, + to_address=account.address, + amount=100000 * 10 ** 6 + ) + + approve( + erc20_contract_address=MockERC20, + web3=web3, + account=account, + spender_address=ROYALTY_MODULE, + amount=100000 * 10 ** 6 + ) + + return { + 'parent_ip_id': parent_ip_id, + 'child_ip_id': child_ip_id, + 'license_terms_id': license_terms_id + } - setup_royalty_vault(story_client, parent_ip_id, account) - - def test_pay_royalty_on_behalf(self, story_client, parent_ip_id, child_ip_id, attach_and_register): + def test_pay_royalty_on_behalf(self, story_client, setup_ips_and_licenses): + """Test paying royalty on behalf of a payer IP to a receiver IP""" + parent_ip_id = setup_ips_and_licenses['parent_ip_id'] + child_ip_id = setup_ips_and_licenses['child_ip_id'] + response = story_client.Royalty.payRoyaltyOnBehalf( receiver_ip_id=parent_ip_id, payer_ip_id=child_ip_id, token=MockERC20, - amount=1000 + amount=1 ) assert response is not None - assert response['txHash'] is not None + assert response['txHash'] is not None and isinstance(response['txHash'], str) - def test_claimable_revenue(self, story_client, parent_ip_id, child_ip_id, attach_and_register): + def test_claimable_revenue(self, story_client, setup_ips_and_licenses): + """Test checking claimable revenue""" + parent_ip_id = setup_ips_and_licenses['parent_ip_id'] + response = story_client.Royalty.claimableRevenue( royalty_vault_ip_id=parent_ip_id, claimer=account.address, token=MockERC20 ) - assert response is not None - assert type(response) == int - assert response > 0 + assert isinstance(response, int) + + def test_pay_royalty_unregistered_receiver(self, story_client, setup_ips_and_licenses): + """Test that paying royalty to unregistered IP fails appropriately""" + child_ip_id = setup_ips_and_licenses['child_ip_id'] + unregistered_ip_id = "0x1234567890123456789012345678901234567890" + + with pytest.raises(ValueError, match=f"The receiver IP with id {unregistered_ip_id} is not registered"): + story_client.Royalty.payRoyaltyOnBehalf( + receiver_ip_id=unregistered_ip_id, + payer_ip_id=child_ip_id, + token=MockERC20, + amount=1000 + ) + + def test_pay_royalty_invalid_amount(self, story_client, setup_ips_and_licenses): + """Test that paying with invalid amount fails appropriately""" + parent_ip_id = setup_ips_and_licenses['parent_ip_id'] + child_ip_id = setup_ips_and_licenses['child_ip_id'] + + with pytest.raises(Exception): + story_client.Royalty.payRoyaltyOnBehalf( + receiver_ip_id=parent_ip_id, + payer_ip_id=child_ip_id, + token=MockERC20, + amount=-1 + ) class TestClaimAllRevenue: @pytest.fixture(scope="module") @@ -185,7 +227,7 @@ def setup_claim_all_revenue(self, story_client): ip_metadata=metadata_b ) ip_b = ip_b_response['ipId'] - ip_b_derivative_response = story_client.IPAsset.registerDerivative( + story_client.IPAsset.registerDerivative( child_ip_id=ip_b, parent_ip_ids=[ip_a], license_terms_ids=[license_terms_id] @@ -197,7 +239,7 @@ def setup_claim_all_revenue(self, story_client): ip_metadata=metadata_c ) ip_c = ip_c_response['ipId'] - ip_c_derivative_response = story_client.IPAsset.registerDerivative( + story_client.IPAsset.registerDerivative( child_ip_id=ip_c, parent_ip_ids=[ip_b], license_terms_ids=[license_terms_id] @@ -209,7 +251,7 @@ def setup_claim_all_revenue(self, story_client): ip_metadata=metadata_d ) ip_d = ip_d_response['ipId'] - ip_d_derivative_response = story_client.IPAsset.registerDerivative( + story_client.IPAsset.registerDerivative( child_ip_id=ip_d, parent_ip_ids=[ip_c], license_terms_ids=[license_terms_id] @@ -340,7 +382,7 @@ def setup_claim_all_revenue_claim_options(self, story_client): ip_metadata=metadata_c ) ip_c = ip_c_response['ipId'] - ip_c_derivative_response = story_client.IPAsset.registerDerivative( + story_client.IPAsset.registerDerivative( child_ip_id=ip_c, parent_ip_ids=[ip_b], license_terms_ids=[license_terms_id] @@ -352,7 +394,7 @@ def setup_claim_all_revenue_claim_options(self, story_client): ip_metadata=metadata_d ) ip_d = ip_d_response['ipId'] - ip_d_derivative_response = story_client.IPAsset.registerDerivative( + story_client.IPAsset.registerDerivative( child_ip_id=ip_d, parent_ip_ids=[ip_c], license_terms_ids=[license_terms_id] @@ -366,37 +408,7 @@ def setup_claim_all_revenue_claim_options(self, story_client): } def test_claim_all_revenue_claim_options(self, setup_claim_all_revenue_claim_options, story_client): - response = story_client.Royalty.claimAllRevenue( - 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']], - royalty_policies=[ROYALTY_POLICY, ROYALTY_POLICY], - currency_tokens=[MockERC20, MockERC20], - claim_options={ - 'autoTransferAllClaimedTokensFromIp': False - } - ) - - # Register IP D as derivative of C - ip_d_response = story_client.IPAsset.mintAndRegisterIp( - spg_nft_contract=spg_nft_contract, - ip_metadata=metadata_d - ) - ip_d = ip_d_response['ipId'] - ip_d_derivative_response = story_client.IPAsset.registerDerivative( - 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): + """Test claiming all revenue with specific claim options""" response = story_client.Royalty.claimAllRevenue( ancestor_ip_id=setup_claim_all_revenue_claim_options['ip_a'], claimer=setup_claim_all_revenue_claim_options['ip_a'], @@ -408,8 +420,6 @@ def test_claim_all_revenue_claim_options(self, setup_claim_all_revenue_claim_opt } ) - print('the response is', response) - assert response is not None assert 'txHashes' in response assert isinstance(response['txHashes'], list) diff --git a/tests/integration/utils.py b/tests/integration/utils.py index 4665ecd..8aee961 100644 --- a/tests/integration/utils.py +++ b/tests/integration/utils.py @@ -10,6 +10,7 @@ # Mock ERC20 contract address (same as used in TypeScript tests) MockERC20 = "0xF2104833d386a2734a4eB3B8ad6FC6812F29E38E" +WIP_TOKEN_ADDRESS = "0x1514000000000000000000000000000000000000"; ZERO_ADDRESS = "0x0000000000000000000000000000000000000000" ROYALTY_POLICY="0xBe54FB168b3c982b7AaE60dB6CF75Bd8447b390E" #Royalty Policy LAP ROYALTY_MODULE="0xD2f60c40fEbccf6311f8B47c4f2Ec6b040400086" From c1c2447e88e081b26f476b68580402e75403c905 Mon Sep 17 00:00:00 2001 From: Andrew Chung <58543094+aandrewchung@users.noreply.github.com> Date: Sat, 22 Mar 2025 02:09:31 +0900 Subject: [PATCH 10/17] [Feature] Added Dispute Module (#49) * Auto generated dispute module classes and arbitration policy uba * Added dispute module and integration test * Separating dispute and ip id for integration test * fixed raiseDispute() bug --- .../ArbitrationPolicyUMA_client.py | 39 + .../abi/DisputeModule/DisputeModule_client.py | 5 + .../abi/jsons/ArbitrationPolicyUMA.json | 939 ++++++++++++++++++ .../resources/Dispute.py | 210 ++++ .../scripts/config.json | 12 +- src/story_protocol_python_sdk/story_client.py | 16 +- src/story_protocol_python_sdk/utils/ipfs.py | 55 + tests/integration/setup_for_integration.py | 12 + tests/integration/test_integration_dispute.py | 79 ++ tests/integration/utils.py | 18 +- 10 files changed, 1381 insertions(+), 4 deletions(-) create mode 100644 src/story_protocol_python_sdk/abi/ArbitrationPolicyUMA/ArbitrationPolicyUMA_client.py create mode 100644 src/story_protocol_python_sdk/abi/jsons/ArbitrationPolicyUMA.json create mode 100644 src/story_protocol_python_sdk/resources/Dispute.py create mode 100644 src/story_protocol_python_sdk/utils/ipfs.py create mode 100644 tests/integration/test_integration_dispute.py diff --git a/src/story_protocol_python_sdk/abi/ArbitrationPolicyUMA/ArbitrationPolicyUMA_client.py b/src/story_protocol_python_sdk/abi/ArbitrationPolicyUMA/ArbitrationPolicyUMA_client.py new file mode 100644 index 0000000..f5af62d --- /dev/null +++ b/src/story_protocol_python_sdk/abi/ArbitrationPolicyUMA/ArbitrationPolicyUMA_client.py @@ -0,0 +1,39 @@ + +import json +import os +from web3 import Web3 + +class ArbitrationPolicyUMAClient: + 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'] == 'ArbitrationPolicyUMA': + contract_address = contract['contract_address'] + break + if not contract_address: + raise ValueError(f"Contract address for ArbitrationPolicyUMA not found in config.json") + abi_path = os.path.join(os.path.dirname(__file__), '..', '..', 'abi', 'jsons', 'ArbitrationPolicyUMA.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 maxBonds(self, token): + + return self.contract.functions.maxBonds(token).call() + + + def maxLiveness(self, ): + + return self.contract.functions.maxLiveness().call() + + + def minLiveness(self, ): + + return self.contract.functions.minLiveness().call() + + \ No newline at end of file diff --git a/src/story_protocol_python_sdk/abi/DisputeModule/DisputeModule_client.py b/src/story_protocol_python_sdk/abi/DisputeModule/DisputeModule_client.py index 79ad0e3..94f7bb4 100644 --- a/src/story_protocol_python_sdk/abi/DisputeModule/DisputeModule_client.py +++ b/src/story_protocol_python_sdk/abi/DisputeModule/DisputeModule_client.py @@ -45,4 +45,9 @@ def resolveDispute(self, disputeId, data): def build_resolveDispute_transaction(self, disputeId, data, tx_params): return self.contract.functions.resolveDispute(disputeId, data).build_transaction(tx_params) + + def isWhitelistedDisputeTag(self, tag): + + return self.contract.functions.isWhitelistedDisputeTag(tag).call() + \ No newline at end of file diff --git a/src/story_protocol_python_sdk/abi/jsons/ArbitrationPolicyUMA.json b/src/story_protocol_python_sdk/abi/jsons/ArbitrationPolicyUMA.json new file mode 100644 index 0000000..08c0da1 --- /dev/null +++ b/src/story_protocol_python_sdk/abi/jsons/ArbitrationPolicyUMA.json @@ -0,0 +1,939 @@ +[ + { + "inputs": [ + { + "internalType": "address", + "name": "disputeModule", + "type": "address" + }, + { + "internalType": "address", + "name": "royaltyModule", + "type": "address" + } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "authority", + "type": "address" + } + ], + "name": "AccessManagedInvalidAuthority", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "caller", + "type": "address" + }, + { + "internalType": "uint32", + "name": "delay", + "type": "uint32" + } + ], + "name": "AccessManagedRequiredDelay", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "caller", + "type": "address" + } + ], + "name": "AccessManagedUnauthorized", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "target", + "type": "address" + } + ], + "name": "AddressEmptyCode", + "type": "error" + }, + { + "inputs": [], + "name": "ArbitrationPolicyUMA__BondAboveMax", + "type": "error" + }, + { + "inputs": [], + "name": "ArbitrationPolicyUMA__CannotCancel", + "type": "error" + }, + { + "inputs": [], + "name": "ArbitrationPolicyUMA__CannotDisputeAssertionIfTagIsInherited", + "type": "error" + }, + { + "inputs": [], + "name": "ArbitrationPolicyUMA__CurrencyNotWhitelisted", + "type": "error" + }, + { + "inputs": [], + "name": "ArbitrationPolicyUMA__DisputeNotFound", + "type": "error" + }, + { + "inputs": [], + "name": "ArbitrationPolicyUMA__IpOwnerTimePercentAboveMax", + "type": "error" + }, + { + "inputs": [], + "name": "ArbitrationPolicyUMA__LivenessAboveMax", + "type": "error" + }, + { + "inputs": [], + "name": "ArbitrationPolicyUMA__LivenessBelowMin", + "type": "error" + }, + { + "inputs": [], + "name": "ArbitrationPolicyUMA__MaxBondBelowMinimumBond", + "type": "error" + }, + { + "inputs": [], + "name": "ArbitrationPolicyUMA__MinLivenessAboveMax", + "type": "error" + }, + { + "inputs": [], + "name": "ArbitrationPolicyUMA__NoCounterEvidence", + "type": "error" + }, + { + "inputs": [], + "name": "ArbitrationPolicyUMA__NotDisputeModule", + "type": "error" + }, + { + "inputs": [], + "name": "ArbitrationPolicyUMA__NotOOV3", + "type": "error" + }, + { + "inputs": [], + "name": "ArbitrationPolicyUMA__OnlyDisputePolicyUMA", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "uint64", + "name": "elapsedTime", + "type": "uint64" + }, + { + "internalType": "uint64", + "name": "liveness", + "type": "uint64" + }, + { + "internalType": "address", + "name": "caller", + "type": "address" + } + ], + "name": "ArbitrationPolicyUMA__OnlyTargetIpIdCanDisputeWithinTimeWindow", + "type": "error" + }, + { + "inputs": [], + "name": "ArbitrationPolicyUMA__ZeroAccessManager", + "type": "error" + }, + { + "inputs": [], + "name": "ArbitrationPolicyUMA__ZeroDisputeModule", + "type": "error" + }, + { + "inputs": [], + "name": "ArbitrationPolicyUMA__ZeroMaxLiveness", + "type": "error" + }, + { + "inputs": [], + "name": "ArbitrationPolicyUMA__ZeroMinLiveness", + "type": "error" + }, + { + "inputs": [], + "name": "ArbitrationPolicyUMA__ZeroOOV3", + "type": "error" + }, + { + "inputs": [], + "name": "ArbitrationPolicyUMA__ZeroRoyaltyModule", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "implementation", + "type": "address" + } + ], + "name": "ERC1967InvalidImplementation", + "type": "error" + }, + { + "inputs": [], + "name": "ERC1967NonPayable", + "type": "error" + }, + { + "inputs": [], + "name": "EnforcedPause", + "type": "error" + }, + { + "inputs": [], + "name": "ExpectedPause", + "type": "error" + }, + { + "inputs": [], + "name": "FailedCall", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidInitialization", + "type": "error" + }, + { + "inputs": [], + "name": "NotInitializing", + "type": "error" + }, + { + "inputs": [], + "name": "ReentrancyGuardReentrantCall", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "token", + "type": "address" + } + ], + "name": "SafeERC20FailedOperation", + "type": "error" + }, + { + "inputs": [], + "name": "UUPSUnauthorizedCallContext", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "slot", + "type": "bytes32" + } + ], + "name": "UUPSUnsupportedProxiableUUID", + "type": "error" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "bytes32", + "name": "assertionId", + "type": "bytes32" + }, + { + "indexed": false, + "internalType": "bytes32", + "name": "counterEvidenceHash", + "type": "bytes32" + } + ], + "name": "AssertionDisputed", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "authority", + "type": "address" + } + ], + "name": "AuthorityUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "disputeId", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "address", + "name": "caller", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint64", + "name": "liveness", + "type": "uint64" + }, + { + "indexed": false, + "internalType": "address", + "name": "currency", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "bond", + "type": "uint256" + } + ], + "name": "DisputeRaisedUMA", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint64", + "name": "version", + "type": "uint64" + } + ], + "name": "Initialized", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint64", + "name": "minLiveness", + "type": "uint64" + }, + { + "indexed": false, + "internalType": "uint64", + "name": "maxLiveness", + "type": "uint64" + }, + { + "indexed": false, + "internalType": "uint32", + "name": "ipOwnerTimePercent", + "type": "uint32" + } + ], + "name": "LivenessSet", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "token", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "maxBond", + "type": "uint256" + } + ], + "name": "MaxBondSet", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "oov3", + "type": "address" + } + ], + "name": "OOV3Set", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "Paused", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "Unpaused", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "implementation", + "type": "address" + } + ], + "name": "Upgraded", + "type": "event" + }, + { + "inputs": [], + "name": "DISPUTE_MODULE", + "outputs": [ + { + "internalType": "contract IDisputeModule", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "ROYALTY_MODULE", + "outputs": [ + { + "internalType": "contract IRoyaltyModule", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "UPGRADE_INTERFACE_VERSION", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "accessManager", + "type": "address" + } + ], + "name": "__ProtocolPausable_init", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "assertionId", + "type": "bytes32" + } + ], + "name": "assertionDisputedCallback", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "assertionId", + "type": "bytes32" + } + ], + "name": "assertionIdToDisputeId", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "assertionId", + "type": "bytes32" + }, + { + "internalType": "bool", + "name": "assertedTruthfully", + "type": "bool" + } + ], + "name": "assertionResolvedCallback", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "authority", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "assertionId", + "type": "bytes32" + }, + { + "internalType": "bytes32", + "name": "counterEvidenceHash", + "type": "bytes32" + } + ], + "name": "disputeAssertion", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "disputeId", + "type": "uint256" + } + ], + "name": "disputeIdToAssertionId", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "accessManager", + "type": "address" + } + ], + "name": "initialize", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "ipOwnerTimePercent", + "outputs": [ + { + "internalType": "uint32", + "name": "", + "type": "uint32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "disputeId", + "type": "uint256" + } + ], + "name": "ipOwnerTimePercents", + "outputs": [ + { + "internalType": "uint32", + "name": "", + "type": "uint32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "isConsumingScheduledOp", + "outputs": [ + { + "internalType": "bytes4", + "name": "", + "type": "bytes4" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "token", + "type": "address" + } + ], + "name": "maxBonds", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "maxLiveness", + "outputs": [ + { + "internalType": "uint64", + "name": "", + "type": "uint64" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "minLiveness", + "outputs": [ + { + "internalType": "uint64", + "name": "", + "type": "uint64" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "caller", + "type": "address" + }, + { + "internalType": "uint256", + "name": "disputeId", + "type": "uint256" + }, + { + "internalType": "bytes", + "name": "data", + "type": "bytes" + } + ], + "name": "onDisputeCancel", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "disputeId", + "type": "uint256" + }, + { + "internalType": "bool", + "name": "decision", + "type": "bool" + }, + { + "internalType": "bytes", + "name": "data", + "type": "bytes" + } + ], + "name": "onDisputeJudgement", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "caller", + "type": "address" + }, + { + "internalType": "address", + "name": "targetIpId", + "type": "address" + }, + { + "internalType": "bytes32", + "name": "disputeEvidenceHash", + "type": "bytes32" + }, + { + "internalType": "bytes32", + "name": "targetTag", + "type": "bytes32" + }, + { + "internalType": "uint256", + "name": "disputeId", + "type": "uint256" + }, + { + "internalType": "bytes", + "name": "data", + "type": "bytes" + } + ], + "name": "onRaiseDispute", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "caller", + "type": "address" + }, + { + "internalType": "uint256", + "name": "disputeId", + "type": "uint256" + }, + { + "internalType": "bytes", + "name": "data", + "type": "bytes" + } + ], + "name": "onResolveDispute", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "oov3", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "pause", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "paused", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "proxiableUUID", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "newAuthority", + "type": "address" + } + ], + "name": "setAuthority", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint64", + "name": "minLiveness", + "type": "uint64" + }, + { + "internalType": "uint64", + "name": "maxLiveness", + "type": "uint64" + }, + { + "internalType": "uint32", + "name": "ipOwnerTimePercent", + "type": "uint32" + } + ], + "name": "setLiveness", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "token", + "type": "address" + }, + { + "internalType": "uint256", + "name": "maxBond", + "type": "uint256" + } + ], + "name": "setMaxBond", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "oov3", + "type": "address" + } + ], + "name": "setOOV3", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "unpause", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "newImplementation", + "type": "address" + }, + { + "internalType": "bytes", + "name": "data", + "type": "bytes" + } + ], + "name": "upgradeToAndCall", + "outputs": [], + "stateMutability": "payable", + "type": "function" + } + ] \ No newline at end of file diff --git a/src/story_protocol_python_sdk/resources/Dispute.py b/src/story_protocol_python_sdk/resources/Dispute.py new file mode 100644 index 0000000..7fbbd68 --- /dev/null +++ b/src/story_protocol_python_sdk/resources/Dispute.py @@ -0,0 +1,210 @@ +from web3 import Web3 +from story_protocol_python_sdk.abi.DisputeModule.DisputeModule_client import DisputeModuleClient +from story_protocol_python_sdk.abi.ArbitrationPolicyUMA.ArbitrationPolicyUMA_client import ArbitrationPolicyUMAClient +from story_protocol_python_sdk.utils.transaction_utils import build_and_send_transaction +from story_protocol_python_sdk.utils.ipfs import convert_cid_to_hash_ipfs +from eth_abi.abi import encode + +ZERO_ADDRESS = "0x0000000000000000000000000000000000000000" + +class Dispute: + """ + A class to manage disputes on Story Protocol. + + :param web3 Web3: An instance of Web3. + :param account: The account to use for transactions. + :param chain_id int: The ID of the blockchain network. + """ + def __init__(self, web3: Web3, account, chain_id: int): + self.web3 = web3 + self.account = account + self.chain_id = chain_id + + self.dispute_module_client = DisputeModuleClient(web3) + self.arbitration_policy_uma_client = ArbitrationPolicyUMAClient(web3) + + def _validate_address(self, address: str) -> str: + """ + Validates if a string is a valid Ethereum address. + + :param address str: The address to validate + :return str: The validated address + :raises ValueError: If the address is invalid + """ + if not self.web3.is_address(address): + raise ValueError(f"Invalid address: {address}.") + return address + + def raise_dispute(self, target_ip_id: str, target_tag: str, cid: str, liveness: int, bond: int, tx_options: dict = None) -> dict: + """ + Raises a dispute on a given IP ID. + + :param target_ip_id str: The IP ID being disputed. + :param target_tag str: The tag to be applied to the IP. + :param cid str: The IPFS CID containing dispute evidence. + :param liveness int: The liveness period for the dispute. + :param bond int: The bond amount for the dispute. + :param tx_options dict: [Optional] Transaction options. + :return dict: A dictionary containing the transaction hash and dispute ID. + """ + try: + # Validate target IP address + target_ip_id = self._validate_address(target_ip_id) + + # Convert tag to bytes32 + tag_bytes = self.web3.to_hex(text=target_tag).ljust(66, '0') + + # Check if tag is whitelisted + is_whitelisted = self.dispute_module_client.isWhitelistedDisputeTag(tag_bytes) + if not is_whitelisted: + raise ValueError(f"The target tag {target_tag} is not whitelisted.") + + # Get liveness bounds + min_liveness = self.arbitration_policy_uma_client.minLiveness() + max_liveness = self.arbitration_policy_uma_client.maxLiveness() + if liveness < min_liveness or liveness > max_liveness: + raise ValueError(f"Liveness must be between {min_liveness} and {max_liveness}.") + + # Check bond amount + max_bonds = self.arbitration_policy_uma_client.maxBonds( + token=self.web3.to_checksum_address("0x1514000000000000000000000000000000000000") + ) + if bond > max_bonds: + raise ValueError(f"Bond must be less than {max_bonds}.") + + # Convert CID to IPFS hash + dispute_evidence_hash = convert_cid_to_hash_ipfs(cid) + + # Encode the data for the arbitration policy + data = encode( + ["uint64", "address", "uint256"], + [ + liveness, + "0x1514000000000000000000000000000000000000", + bond + ] + ) + + + response = build_and_send_transaction( + self.web3, + self.account, + self.dispute_module_client.build_raiseDispute_transaction, + target_ip_id, + dispute_evidence_hash, + tag_bytes, + data, + tx_options=tx_options + ) + + dispute_id = self._parse_tx_dispute_raised_event(response['txReceipt']) + + return { + 'txHash': response['txHash'], + 'disputeId': dispute_id if dispute_id else None + } + + except Exception as e: + raise ValueError(f"Failed to raise dispute: {str(e)}") + + def cancel_dispute(self, dispute_id: int, data: str = "0x", tx_options: dict = None) -> dict: + """ + Cancels an ongoing dispute. + + :param dispute_id int: The ID of the dispute to cancel. + :param data str: [Optional] Additional data for the cancellation. + :param tx_options dict: [Optional] Transaction options. + :return dict: A dictionary containing the transaction hash. + """ + try: + response = build_and_send_transaction( + self.web3, + self.account, + self.dispute_module_client.build_cancelDispute_transaction, + dispute_id, + data, + tx_options=tx_options + ) + + return { + 'txHash': response['txHash'] + } + + except Exception as e: + raise ValueError(f"Failed to cancel dispute: {str(e)}") + + def resolve_dispute(self, dispute_id: int, data: str, tx_options: dict = None) -> dict: + """ + Resolves a dispute after it has been judged. + + :param dispute_id int: The ID of the dispute to resolve. + :param data str: The resolution data. + :param tx_options dict: [Optional] Transaction options. + :return dict: A dictionary containing the transaction hash. + """ + try: + response = build_and_send_transaction( + self.web3, + self.account, + self.dispute_module_client.build_resolveDispute_transaction, + dispute_id, + data, + tx_options=tx_options + ) + + return { + 'txHash': response['txHash'] + } + + except Exception as e: + raise ValueError(f"Failed to resolve dispute: {str(e)}") + + def tag_if_related_ip_infringed(self, infringement_tags: list, tx_options: dict = None) -> list: + """ + Tags a derivative if a parent has been tagged with an infringement tag. + + :param infringement_tags list: List of dictionaries containing IP IDs and dispute IDs. + :param ip_id str: The IP ID to tag. + :param dispute_id int: The ID of the dispute. + :param tx_options dict: [Optional] Transaction options. + :return list: A list of transaction hashes. + """ + try: + tx_hashes = [] + + for tag in infringement_tags: + ip_id = self._validate_address(tag['ip_id']) + + response = build_and_send_transaction( + self.web3, + self.account, + self.dispute_module_client.build_tagIfRelatedIpInfringed_transaction, + ip_id, + tag['dispute_id'], + tx_options=tx_options + ) + + tx_hashes.append(response['txHash']) + + return tx_hashes + + except Exception as e: + raise ValueError(f"Failed to tag related IP infringed: {str(e)}") + + def _parse_tx_dispute_raised_event(self, tx_receipt: dict) -> dict: + """ + Parse the DisputeRaised event from a transaction receipt. + + :param tx_receipt dict: The transaction receipt. + :return dict: The dispute ID from the event. + """ + event_signature = self.web3.keccak( + text="DisputeRaised(uint256,address,address,uint256,address,bytes32,bytes32,bytes)" + ).hex() + + for log in tx_receipt['logs']: + if log['topics'][0].hex() == event_signature: + data = log['data'] + dispute_id = int.from_bytes(data[:32], byteorder='big') + return dispute_id + return None diff --git a/src/story_protocol_python_sdk/scripts/config.json b/src/story_protocol_python_sdk/scripts/config.json index 6e15035..6f53e22 100644 --- a/src/story_protocol_python_sdk/scripts/config.json +++ b/src/story_protocol_python_sdk/scripts/config.json @@ -21,7 +21,8 @@ "DisputeResolved", "cancelDispute", "raiseDispute", - "resolveDispute" + "resolveDispute", + "isWhitelistedDisputeTag" ] }, { @@ -211,6 +212,15 @@ "contract_address": "0x9156e603C949481883B1d3355c6f1132D191fC41", "functions": [] }, + { + "contract_name": "ArbitrationPolicyUMA", + "contract_address": "0xfFD98c3877B8789124f02C7E8239A4b0Ef11E936", + "functions": [ + "minLiveness", + "maxLiveness", + "maxBonds" + ] + }, { "contract_name": "MockERC20", "contract_address": "0xF2104833d386a2734a4eB3B8ad6FC6812F29E38E", diff --git a/src/story_protocol_python_sdk/story_client.py b/src/story_protocol_python_sdk/story_client.py index 49d2fd2..3b63aba 100644 --- a/src/story_protocol_python_sdk/story_client.py +++ b/src/story_protocol_python_sdk/story_client.py @@ -15,6 +15,7 @@ from story_protocol_python_sdk.resources.IPAccount import IPAccount from story_protocol_python_sdk.resources.Permission import Permission from story_protocol_python_sdk.resources.NFTClient import NFTClient +from story_protocol_python_sdk.resources.Dispute import Dispute class StoryClient: """ @@ -49,7 +50,7 @@ def __init__(self, web3, account, chain_id: int): self._ip_account = None self._permission = None self._nft_client = None - + self._dispute = None @property def IPAsset(self) -> IPAsset: """ @@ -114,4 +115,15 @@ def NFTClient(self) -> NFTClient: """ if self._nft_client is None: self._nft_client = NFTClient(self.web3, self.account, self.chain_id) - return self._nft_client \ No newline at end of file + return self._nft_client + + @property + def Dispute(self) -> Dispute: + """ + Access the Dispute resource. + + :return Dispute: An instance of Dispute. + """ + if self._dispute is None: + self._dispute = Dispute(self.web3, self.account, self.chain_id) + return self._dispute \ No newline at end of file diff --git a/src/story_protocol_python_sdk/utils/ipfs.py b/src/story_protocol_python_sdk/utils/ipfs.py new file mode 100644 index 0000000..2c6e83a --- /dev/null +++ b/src/story_protocol_python_sdk/utils/ipfs.py @@ -0,0 +1,55 @@ +import base58 +from typing import Union + +V0_PREFIX = "1220" + +def convert_cid_to_hash_ipfs(cid: str) -> str: + """ + Convert an IPFS CID to a hex hash. + + Args: + cid: IPFS CID string + + Returns: + Hex string starting with '0x' + """ + # Check if CID is v0 (starts with "Qm") + is_v0 = cid.startswith("Qm") + + # Decode base58 CID + bytes_array = base58.b58decode(cid) + + # Convert bytes to hex string + base16_cid = ''.join([f'{b:02x}' for b in bytes_array]) + + # Remove v0 prefix and add 0x + return "0x" + base16_cid[len(V0_PREFIX):] + +def convert_hash_ipfs_to_cid(hash_str: str, version: str = "v0") -> str: + """ + Convert a hex hash back to IPFS CID. + + Args: + hash_str: Hex string starting with '0x' + version: CID version ("v0" or "v1"), defaults to "v0" + + Returns: + IPFS CID string + """ + if not hash_str.startswith("0x"): + raise ValueError("Hash must start with '0x'") + + # Add v0 prefix back + base16_cid = V0_PREFIX + hash_str[2:] + + # Convert hex string to bytes + bytes_array = bytes.fromhex(base16_cid) + + # Encode to base58 + base58_cid = base58.b58encode(bytes_array).decode() + + # For now we only support v0 since v1 requires additional dependencies + if version == "v1": + raise NotImplementedError("CID v1 conversion not yet supported") + + return base58_cid diff --git a/tests/integration/setup_for_integration.py b/tests/integration/setup_for_integration.py index 277a839..c9b476b 100644 --- a/tests/integration/setup_for_integration.py +++ b/tests/integration/setup_for_integration.py @@ -24,6 +24,8 @@ ROYALTY_POLICY, ROYALTY_MODULE, PIL_LICENSE_TEMPLATE, + ARBITRATION_POLICY_UMA, + generate_cid, WIP_TOKEN_ADDRESS, setup_royalty_vault ) @@ -31,6 +33,7 @@ # Load environment variables load_dotenv(override=True) private_key = os.getenv('WALLET_PRIVATE_KEY') +private_key_2 = os.getenv('WALLET_PRIVATE_KEY_2') rpc_url = os.getenv('RPC_PROVIDER_URL') if not private_key: @@ -45,11 +48,16 @@ # Set up the account with the private key account = web3.eth.account.from_key(private_key) +account_2 = web3.eth.account.from_key(private_key_2) @pytest.fixture(scope="session") def story_client(): return get_story_client_in_devnet(web3, account) +@pytest.fixture(scope="session") +def story_client_2(): + return get_story_client_in_devnet(web3, account_2) + # Export everything needed by test files __all__ = [ 'web3', @@ -66,6 +74,10 @@ def story_client(): 'ROYALTY_POLICY' 'ROYALTY_MODULE', 'PIL_LICENSE_TEMPLATE', + 'ARBITRATION_POLICY_UMA', + 'account_2', + 'story_client_2', + 'generate_cid' 'setup_royalty_vault', 'WIP_TOKEN_ADDRESS' ] \ No newline at end of file diff --git a/tests/integration/test_integration_dispute.py b/tests/integration/test_integration_dispute.py new file mode 100644 index 0000000..bb9388a --- /dev/null +++ b/tests/integration/test_integration_dispute.py @@ -0,0 +1,79 @@ +import pytest +from web3 import Web3 + +from setup_for_integration import ( + web3, + account, + account_2, + story_client, + story_client_2, + get_token_id, + mint_tokens, + approve, + getBlockTimestamp, + check_event_in_tx, + MockERC721, + MockERC20, + ZERO_ADDRESS, + ARBITRATION_POLICY_UMA, + generate_cid +) + +@pytest.fixture(scope="module") +def target_ip_id(story_client_2): + """Create an IP to be disputed""" + token_id = get_token_id(MockERC721, story_client_2.web3, story_client_2.account) + + response = story_client_2.IPAsset.register( + nft_contract=MockERC721, + token_id=token_id + ) + + return response['ipId'] + +@pytest.fixture(scope="module") +def dispute_id(story_client, target_ip_id): + """Create a dispute and return its ID""" + + cid = generate_cid() + + response = story_client.Dispute.raise_dispute( + target_ip_id=target_ip_id, + target_tag="IMPROPER_REGISTRATION", + cid=cid, + liveness=2592000, # 30 days in seconds + bond=0, + tx_options={"wait_for_transaction": True} + ) + + assert 'txHash' in response + assert isinstance(response['txHash'], str) + assert 'disputeId' in response + assert isinstance(response['disputeId'], int) + return response['disputeId'] + +def test_raise_dispute(story_client, dispute_id): + """Test raising a dispute""" + assert dispute_id is not None + assert isinstance(dispute_id, int) + assert dispute_id > 0 + +# def test_raise_dispute_with_bond(story_client, target_ip_id): +# """Test raising a dispute with a bond amount""" +# # Approve tokens first +# approve(story_client.account, ARBITRATION_POLICY_UMA) + +# response = story_client.Dispute.raise_dispute( +# target_ip_id=target_ip_id, +# target_tag="IMPROPER_REGISTRATION", +# cid="QmX4zdp8VpzqvtKuEqMo6gfZPdoUx9TeHXCgzKLcFfSUbk", +# liveness=2592000, +# bond=1000000, # Use a valid bond amount +# tx_options={"wait_for_transaction": True} +# ) + +# assert 'txHash' in response +# assert isinstance(response['txHash'], str) +# assert len(response['txHash']) > 0 +# assert 'disputeId' in response +# assert isinstance(response['disputeId'], int) diff --git a/tests/integration/utils.py b/tests/integration/utils.py index 8aee961..0de02e0 100644 --- a/tests/integration/utils.py +++ b/tests/integration/utils.py @@ -1,6 +1,9 @@ from web3 import Web3 from dotenv import load_dotenv from src.story_protocol_python_sdk.story_client import StoryClient +import os +import hashlib +import base58 load_dotenv() @@ -15,6 +18,7 @@ ROYALTY_POLICY="0xBe54FB168b3c982b7AaE60dB6CF75Bd8447b390E" #Royalty Policy LAP ROYALTY_MODULE="0xD2f60c40fEbccf6311f8B47c4f2Ec6b040400086" PIL_LICENSE_TEMPLATE="0x2E896b0b2Fdb7457499B56AAaA4AE55BCB4Cd316" +ARBITRATION_POLICY_UMA="0xfFD98c3877B8789124f02C7E8239A4b0Ef11E936" def get_story_client_in_sepolia(web3: Web3, account) -> StoryClient: chain_id = 11155111 # Sepolia chain ID @@ -181,6 +185,18 @@ def check_event_in_tx(web3, tx_hash: str, event_text: str) -> bool: return False +def generate_cid() -> str: + """Generate a random CIDv0 for testing purposes""" + # Generate random bytes + random_bytes = os.urandom(32) + # Hash using SHA-256 + sha256_hash = hashlib.sha256(random_bytes).digest() + # Construct CIDv0 (SHA-256 + multihash prefix) + multihash = bytes([0x12, 0x20]) + sha256_hash + # Base58 encode + return base58.b58encode(multihash).decode('utf-8') + + def setup_royalty_vault(story_client, parent_ip_id, account): parent_ip_royalty_address = story_client.Royalty.getRoyaltyVaultAddress(parent_ip_id) @@ -196,4 +212,4 @@ def setup_royalty_vault(story_client, parent_ip_id, account): data=transfer_data ) - return response \ No newline at end of file + return response From 70f771c577474e2dc16f88409820a44db199d91b Mon Sep 17 00:00:00 2001 From: Boris Polania Date: Mon, 24 Mar 2025 09:28:19 -0700 Subject: [PATCH 11/17] Dispute integration tests (#50) * Dispute integration tests * Update test_integration_dispute.py --- setup.py | 3 +- tests/integration/setup_for_integration.py | 1 + tests/integration/test_integration_dispute.py | 301 +++++++++++++++--- 3 files changed, 251 insertions(+), 54 deletions(-) diff --git a/setup.py b/setup.py index 655e893..b0b9056 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,8 @@ install_requires=[ 'web3>=7.0.0', 'pytest', - 'python-dotenv' + 'python-dotenv', + 'base58' ], include_package_data=True, # Ensure package data is included url='https://github.com/storyprotocol/python-sdk', diff --git a/tests/integration/setup_for_integration.py b/tests/integration/setup_for_integration.py index c9b476b..7338d1e 100644 --- a/tests/integration/setup_for_integration.py +++ b/tests/integration/setup_for_integration.py @@ -62,6 +62,7 @@ def story_client_2(): __all__ = [ 'web3', 'account', + 'account_2', 'story_client', 'get_token_id', 'mint_tokens', diff --git a/tests/integration/test_integration_dispute.py b/tests/integration/test_integration_dispute.py index bb9388a..5538fc0 100644 --- a/tests/integration/test_integration_dispute.py +++ b/tests/integration/test_integration_dispute.py @@ -1,9 +1,10 @@ import pytest +import time from web3 import Web3 from setup_for_integration import ( web3, - account, + account, account_2, story_client, story_client_2, @@ -16,64 +17,258 @@ MockERC20, ZERO_ADDRESS, ARBITRATION_POLICY_UMA, + PIL_LICENSE_TEMPLATE, + ROYALTY_POLICY, generate_cid ) -@pytest.fixture(scope="module") -def target_ip_id(story_client_2): - """Create an IP to be disputed""" - token_id = get_token_id(MockERC721, story_client_2.web3, story_client_2.account) - - response = story_client_2.IPAsset.register( - nft_contract=MockERC721, - token_id=token_id - ) +class TestDispute: + @pytest.fixture(scope="module") + def target_ip_id(self, story_client, story_client_2): + """Create an IP to be disputed""" + txData = story_client.NFTClient.createNFTCollection( + name="test-collection", + symbol="TEST", + max_supply=100, + is_public_minting=True, + mint_open=True, + contract_uri="test-uri", + mint_fee_recipient=account.address, + tx_options={ "wait_for_transaction": True} + ) + + nft_contract = txData['nftContract'] - return response['ipId'] + 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")) + } + + response = story_client_2.IPAsset.mintAndRegisterIp( + spg_nft_contract=nft_contract, + ip_metadata=metadata_a, + tx_options={ "wait_for_transaction": True} + ) + return response['ipId'] -@pytest.fixture(scope="module") -def dispute_id(story_client, target_ip_id): - """Create a dispute and return its ID""" + @pytest.fixture(scope="module") + def dispute_id(self, story_client, target_ip_id): + """Create a dispute and return its ID""" + cid = generate_cid() - cid = generate_cid() + response = story_client.Dispute.raise_dispute( + target_ip_id=target_ip_id, + target_tag="IMPROPER_REGISTRATION", + cid=cid, + liveness=2592000, # 30 days in seconds + bond=0, + tx_options={"wait_for_transaction": True} + ) + + assert 'txHash' in response + assert isinstance(response['txHash'], str) + assert 'disputeId' in response + assert isinstance(response['disputeId'], int) + return response['disputeId'] - response = story_client.Dispute.raise_dispute( - target_ip_id=target_ip_id, - target_tag="IMPROPER_REGISTRATION", - cid=cid, - liveness=2592000, # 30 days in seconds - bond=0, - tx_options={"wait_for_transaction": True} - ) - - assert 'txHash' in response - assert isinstance(response['txHash'], str) - assert 'disputeId' in response - assert isinstance(response['disputeId'], int) - return response['disputeId'] + @pytest.fixture(scope="module") + def parent_ip_with_license(self, story_client): + """Create a parent IP with license terms using mintAndRegisterIpAssetWithPilTerms""" + + txData = story_client.NFTClient.createNFTCollection( + name="parent-collection", + symbol="PRNT", + max_supply=100, + is_public_minting=True, + mint_open=True, + contract_uri="test-uri", + mint_fee_recipient=account.address, + ) + nft_collection = txData['nftContract'] + + response = story_client.IPAsset.mintAndRegisterIpAssetWithPilTerms( + spg_nft_contract=nft_collection, + terms=[{ + 'terms': { + 'transferable': True, + 'royalty_policy': ROYALTY_POLICY, + 'default_minting_fee': 0, + 'expiration': 0, + 'commercial_use': True, + 'commercial_attribution': False, + 'commercializer_checker': ZERO_ADDRESS, + 'commercializer_checker_data': ZERO_ADDRESS, + 'commercial_rev_share': 90, + 'commercial_rev_ceiling': 0, + 'derivatives_allowed': True, + 'derivatives_attribution': True, + 'derivatives_approval': False, + 'derivatives_reciprocal': True, + 'derivative_rev_ceiling': 0, + 'currency': MockERC20, + 'uri': "" + }, + 'licensing_config': { + 'is_set': True, + 'minting_fee': 0, + 'hook_data': "", + 'licensing_hook': ZERO_ADDRESS, + 'commercial_rev_share': 90, + 'disabled': False, + 'expect_minimum_group_reward_share': 0, + 'expect_group_reward_pool': ZERO_ADDRESS + } + }] + ) + + return { + 'ip_id': response['ipId'], + 'license_terms_id': response['licenseTermsIds'][0], + 'nft_contract': nft_collection + } -def test_raise_dispute(story_client, dispute_id): - """Test raising a dispute""" - assert dispute_id is not None - assert isinstance(dispute_id, int) - assert dispute_id > 0 + @pytest.fixture(scope="module") + def child_ip_id(self, story_client, parent_ip_with_license): + """Create a derivative IP (child IP) from the parent IP""" + derivative_response = story_client.IPAsset.mintAndRegisterIpAndMakeDerivative( + spg_nft_contract=parent_ip_with_license['nft_contract'], + deriv_data={ + 'parent_ip_ids': [parent_ip_with_license['ip_id']], + 'license_terms_ids': [parent_ip_with_license['license_terms_id']], + 'max_minting_fee': 1, + 'max_rts': 5 * 10 ** 6, + 'max_revenue_share': 100 + }, + tx_options={"wait_for_transaction": True} + ) + + return derivative_response['ipId'] -# def test_raise_dispute_with_bond(story_client, target_ip_id): -# """Test raising a dispute with a bond amount""" -# # Approve tokens first -# approve(story_client.account, ARBITRATION_POLICY_UMA) - -# response = story_client.Dispute.raise_dispute( -# target_ip_id=target_ip_id, -# target_tag="IMPROPER_REGISTRATION", -# cid="QmX4zdp8VpzqvtKuEqMo6gfZPdoUx9TeHXCgzKLcFfSUbk", -# liveness=2592000, -# bond=1000000, # Use a valid bond amount -# tx_options={"wait_for_transaction": True} -# ) - -# assert 'txHash' in response -# assert isinstance(response['txHash'], str) -# assert len(response['txHash']) > 0 -# assert 'disputeId' in response -# assert isinstance(response['disputeId'], int) + def test_raise_dispute(self, story_client, target_ip_id): + """Test raising a dispute""" + cid = generate_cid() + + response = story_client.Dispute.raise_dispute( + target_ip_id=target_ip_id, + target_tag="IMPROPER_REGISTRATION", + cid=cid, + liveness=2592000, # 30 days in seconds + bond=0, + tx_options={"wait_for_transaction": True} + ) + + assert 'txHash' in response + assert isinstance(response['txHash'], str) + assert len(response['txHash']) > 0 + assert 'disputeId' in response + assert isinstance(response['disputeId'], int) + assert response['disputeId'] > 0 + + def test_cancel_dispute_unauthorized(self, story_client_2, dispute_id): + """Test attempting to cancel a dispute by an unauthorized account""" + with pytest.raises(ValueError) as excinfo: + story_client_2.Dispute.cancel_dispute( + dispute_id=dispute_id, + tx_options={"wait_for_transaction": True} + ) + + assert "Failed to cancel dispute" in str(excinfo.value) + + def test_resolve_dispute_unauthorized(self, story_client_2, dispute_id): + """Test attempting to resolve a dispute by an unauthorized account""" + with pytest.raises(ValueError) as excinfo: + story_client_2.Dispute.resolve_dispute( + dispute_id=dispute_id, + data="0x", + tx_options={"wait_for_transaction": True} + ) + + assert "Failed to resolve dispute" in str(excinfo.value) + + def test_raise_dispute_invalid_tag(self, story_client, target_ip_id): + """Test raising a dispute with an invalid tag""" + cid = generate_cid() + + with pytest.raises(ValueError) as excinfo: + story_client.Dispute.raise_dispute( + target_ip_id=target_ip_id, + target_tag="INVALID_TAG", + cid=cid, + liveness=2592000, + bond=0, + tx_options={"wait_for_transaction": True} + ) + + assert "not whitelisted" in str(excinfo.value) + + def test_raise_dispute_invalid_liveness(self, story_client, target_ip_id): + """Test raising a dispute with invalid liveness period""" + min_liveness = story_client.Dispute.arbitration_policy_uma_client.minLiveness() + + cid = generate_cid() + + with pytest.raises(ValueError) as excinfo: + story_client.Dispute.raise_dispute( + target_ip_id=target_ip_id, + target_tag="IMPROPER_REGISTRATION", + cid=cid, + liveness=int(min_liveness) - 1, + bond=0, + tx_options={"wait_for_transaction": True} + ) + + assert "Liveness must be between" in str(excinfo.value) + + max_liveness = story_client.Dispute.arbitration_policy_uma_client.maxLiveness() + + with pytest.raises(ValueError) as excinfo: + story_client.Dispute.raise_dispute( + target_ip_id=target_ip_id, + target_tag="IMPROPER_REGISTRATION", + cid=cid, + liveness=int(max_liveness) + 1, + bond=0, + tx_options={"wait_for_transaction": True} + ) + + assert "Liveness must be between" in str(excinfo.value) + + def test_raise_dispute_invalid_bond(self, story_client, target_ip_id): + """Test raising a dispute with an excessive bond amount""" + cid = generate_cid() + + max_bonds = story_client.Dispute.arbitration_policy_uma_client.maxBonds( + token=web3.to_checksum_address("0x1514000000000000000000000000000000000000") + ) + + with pytest.raises(ValueError) as excinfo: + story_client.Dispute.raise_dispute( + target_ip_id=target_ip_id, + target_tag="IMPROPER_REGISTRATION", + cid=cid, + liveness=2592000, + bond=int(max_bonds) + 1, + tx_options={"wait_for_transaction": True} + ) + + assert "Bond must be less than" in str(excinfo.value) + + def test_raise_dispute_invalid_address(self, story_client): + """Test raising a dispute with an invalid IP address""" + cid = generate_cid() + + with pytest.raises(ValueError) as excinfo: + story_client.Dispute.raise_dispute( + target_ip_id="not-an-address", + target_tag="IMPROPER_REGISTRATION", + cid=cid, + liveness=2592000, + bond=0, + tx_options={"wait_for_transaction": True} + ) + + assert "Invalid address" in str(excinfo.value) + + \ No newline at end of file From 06708b1b38696dede3417c9c3905f635c45275a4 Mon Sep 17 00:00:00 2001 From: Andrew Chung <58543094+aandrewchung@users.noreply.github.com> Date: Thu, 27 Mar 2025 02:09:46 +0900 Subject: [PATCH 12/17] [FEATURE] Added WIP Module (#51) * Added wip autogenerated python class * Added value txn option and wallet address for test setup * Added WIP module & getWalletBalance() * Added integration test for deposit, transfer, & withdraw --- .../abi/WIP/WIP_client.py | 69 +++ .../abi/jsons/WIP.json | 428 ++++++++++++++++++ .../resources/WIP.py | 198 ++++++++ .../scripts/config.json | 12 + src/story_protocol_python_sdk/story_client.py | 28 +- .../utils/transaction_utils.py | 4 + tests/integration/setup_for_integration.py | 8 +- tests/integration/test_integration_wip.py | 104 +++++ 8 files changed, 848 insertions(+), 3 deletions(-) create mode 100644 src/story_protocol_python_sdk/abi/WIP/WIP_client.py create mode 100644 src/story_protocol_python_sdk/abi/jsons/WIP.json create mode 100644 src/story_protocol_python_sdk/resources/WIP.py create mode 100644 tests/integration/test_integration_wip.py diff --git a/src/story_protocol_python_sdk/abi/WIP/WIP_client.py b/src/story_protocol_python_sdk/abi/WIP/WIP_client.py new file mode 100644 index 0000000..7bb4933 --- /dev/null +++ b/src/story_protocol_python_sdk/abi/WIP/WIP_client.py @@ -0,0 +1,69 @@ + +import json +import os +from web3 import Web3 + +class WIPClient: + 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'] == 'WIP': + contract_address = contract['contract_address'] + break + if not contract_address: + raise ValueError(f"Contract address for WIP not found in config.json") + abi_path = os.path.join(os.path.dirname(__file__), '..', '..', 'abi', 'jsons', 'WIP.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 approve(self, spender, amount): + + return self.contract.functions.approve(spender, amount).transact() + + def build_approve_transaction(self, spender, amount, tx_params): + return self.contract.functions.approve(spender, amount).build_transaction(tx_params) + + + def deposit(self, ): + + return self.contract.functions.deposit().transact() + + def build_deposit_transaction(self, tx_params): + return self.contract.functions.deposit().build_transaction(tx_params) + + + def transfer(self, to, amount): + + return self.contract.functions.transfer(to, amount).transact() + + def build_transfer_transaction(self, to, amount, tx_params): + return self.contract.functions.transfer(to, amount).build_transaction(tx_params) + + + def transferFrom(self, from_address, to, amount): + + return self.contract.functions.transferFrom(from_address, to, amount).transact() + + def build_transferFrom_transaction(self, from_address, to, amount, tx_params): + return self.contract.functions.transferFrom(from_address, to, amount).build_transaction(tx_params) + + + 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() + + \ No newline at end of file diff --git a/src/story_protocol_python_sdk/abi/jsons/WIP.json b/src/story_protocol_python_sdk/abi/jsons/WIP.json new file mode 100644 index 0000000..4d2b58a --- /dev/null +++ b/src/story_protocol_python_sdk/abi/jsons/WIP.json @@ -0,0 +1,428 @@ +[ + { + "inputs": [], + "name": "AllowanceOverflow", + "type": "error" + }, + { + "inputs": [], + "name": "AllowanceUnderflow", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "receiver", + "type": "address" + } + ], + "name": "ERC20InvalidReceiver", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "spender", + "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": "error" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "Approval", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "Deposit", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "Transfer", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "Withdrawal", + "type": "event" + }, + { + "inputs": [], + "name": "DOMAIN_SEPARATOR", + "outputs": [ + { + "internalType": "bytes32", + "name": "result", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "internalType": "address", + "name": "spender", + "type": "address" + } + ], + "name": "allowance", + "outputs": [ + { + "internalType": "uint256", + "name": "result", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "approve", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + } + ], + "name": "balanceOf", + "outputs": [ + { + "internalType": "uint256", + "name": "result", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "decimals", + "outputs": [ + { + "internalType": "uint8", + "name": "", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "deposit", + "outputs": [], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [], + "name": "name", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "pure", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + } + ], + "name": "nonces", + "outputs": [ + { + "internalType": "uint256", + "name": "result", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "value", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "deadline", + "type": "uint256" + }, + { + "internalType": "uint8", + "name": "v", + "type": "uint8" + }, + { + "internalType": "bytes32", + "name": "r", + "type": "bytes32" + }, + { + "internalType": "bytes32", + "name": "s", + "type": "bytes32" + } + ], + "name": "permit", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "symbol", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "pure", + "type": "function" + }, + { + "inputs": [], + "name": "totalSupply", + "outputs": [ + { + "internalType": "uint256", + "name": "result", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "transfer", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "transferFrom", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "withdraw", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "stateMutability": "payable", + "type": "receive" + } + ] \ No newline at end of file diff --git a/src/story_protocol_python_sdk/resources/WIP.py b/src/story_protocol_python_sdk/resources/WIP.py new file mode 100644 index 0000000..3ca8d58 --- /dev/null +++ b/src/story_protocol_python_sdk/resources/WIP.py @@ -0,0 +1,198 @@ +"""Module for handling Wrapped IP (WIP) token operations.""" + +from web3 import Web3 + +from story_protocol_python_sdk.abi.WIP.WIP_client import WIPClient +from story_protocol_python_sdk.utils.transaction_utils import build_and_send_transaction + +class WIP: + """ + A class to manage Wrapped IP (WIP) token operations. + + :param web3 Web3: An instance of Web3. + :param account: The account to use for transactions. + :param chain_id int: The ID of the blockchain network. + """ + def __init__(self, web3: Web3, account, chain_id: int): + self.web3 = web3 + self.account = account + self.chain_id = chain_id + + self.wip_client = WIPClient(web3) + + def deposit(self, amount: int, tx_options: dict = None) -> dict: + """ + Wraps the selected amount of IP to WIP. + The WIP will be deposited to the wallet that transferred the IP. + + :param amount int: The amount of IP to wrap. + :param tx_options dict: [Optional] Transaction options. + :return dict: A dictionary containing the transaction hash. + """ + try: + if amount <= 0: + raise ValueError("WIP deposit amount must be greater than 0.") + + # Prepare transaction options + transaction_options = tx_options or {} + transaction_options.update({ + 'from': self.account.address, + 'nonce': self.web3.eth.get_transaction_count(self.account.address), + 'value': amount + }) + + response = build_and_send_transaction( + self.web3, + self.account, + self.wip_client.build_deposit_transaction, + tx_options=transaction_options + ) + + return {'txHash': response['txHash']} + + except Exception as e: + raise ValueError(f"Failed to deposit IP for WIP: {str(e)}") + + def withdraw(self, amount: int, tx_options: dict = None) -> dict: + """ + Unwraps the selected amount of WIP to IP. + + :param amount int: The amount of WIP to unwrap. + :param tx_options dict: [Optional] Transaction options. + :return dict: A dictionary containing the transaction hash. + """ + try: + if amount <= 0: + raise ValueError("WIP withdraw amount must be greater than 0.") + + response = build_and_send_transaction( + self.web3, + self.account, + self.wip_client.build_withdraw_transaction, + amount, + tx_options=tx_options + ) + + return {'txHash': response['txHash']} + + except Exception as e: + raise ValueError(f"Failed to withdraw WIP: {str(e)}") + + def approve(self, spender: str, amount: int, tx_options: dict = None) -> dict: + """ + Approve a spender to use the wallet's WIP balance. + + :param spender str: The address of the spender. + :param amount int: The amount of WIP to approve. + :param tx_options dict: [Optional] Transaction options. + :return dict: A dictionary containing the transaction hash. + """ + try: + if amount <= 0: + raise ValueError("WIP approve amount must be greater than 0.") + + if not self.web3.is_address(spender): + raise ValueError(f"The spender address {spender} is not valid.") + + spender = self.web3.to_checksum_address(spender) + + response = build_and_send_transaction( + self.web3, + self.account, + self.wip_client.build_approve_transaction, + spender, + amount, + tx_options=tx_options + ) + + return {'txHash': response['txHash']} + + except Exception as e: + raise ValueError(f"Failed to approve WIP: {str(e)}") + + def balanceOf(self, address: str) -> int: + """ + Returns the balance of WIP for an address. + + :param address str: The address to check the balance of. + :return int: The WIP balance of the address. + """ + try: + if not self.web3.is_address(address): + raise ValueError(f"The address {address} is not valid.") + + address = self.web3.to_checksum_address(address) + return self.wip_client.balanceOf(address) + + except Exception as e: + raise ValueError(f"Failed to get WIP balance: {str(e)}") + + def transfer(self, to: str, amount: int, tx_options: dict = None) -> dict: + """ + Transfers `amount` of WIP to a recipient `to`. + + :param to str: The address of the recipient. + :param amount int: The amount of WIP to transfer. + :param tx_options dict: [Optional] Transaction options. + :return dict: A dictionary containing the transaction hash. + """ + try: + if amount <= 0: + raise ValueError("WIP transfer amount must be greater than 0.") + + if not self.web3.is_address(to): + raise ValueError(f"The recipient address {to} is not valid.") + + to = self.web3.to_checksum_address(to) + + response = build_and_send_transaction( + self.web3, + self.account, + self.wip_client.build_transfer_transaction, + to, + amount, + tx_options=tx_options + ) + + return {'txHash': response['txHash']} + + except Exception as e: + raise ValueError(f"Failed to transfer WIP: {str(e)}") + + def transferFrom(self, from_address: str, to: str, amount: int, tx_options: dict = None) -> dict: + """ + Transfers `amount` of WIP from `from_address` to a recipient `to`. + + :param from_address str: The address to transfer from. + :param to str: The address of the recipient. + :param amount int: The amount of WIP to transfer. + :param tx_options dict: [Optional] Transaction options. + :return dict: A dictionary containing the transaction hash. + """ + try: + if amount <= 0: + raise ValueError("WIP transfer amount must be greater than 0.") + + if not self.web3.is_address(from_address): + raise ValueError(f"The from address {from_address} is not valid.") + + if not self.web3.is_address(to): + raise ValueError(f"The recipient address {to} is not valid.") + + from_address = self.web3.to_checksum_address(from_address) + to = self.web3.to_checksum_address(to) + + response = build_and_send_transaction( + self.web3, + self.account, + self.wip_client.build_transferFrom_transaction, + from_address, + to, + amount, + tx_options=tx_options + ) + + return {'txHash': response['txHash']} + + except Exception as e: + raise ValueError(f"Failed to transfer WIP from another address: {str(e)}") diff --git a/src/story_protocol_python_sdk/scripts/config.json b/src/story_protocol_python_sdk/scripts/config.json index 6f53e22..9b1b5fd 100644 --- a/src/story_protocol_python_sdk/scripts/config.json +++ b/src/story_protocol_python_sdk/scripts/config.json @@ -228,6 +228,18 @@ "transfer", "balanceOf" ] + }, + { + "contract_name": "WIP", + "contract_address": "0x1514000000000000000000000000000000000000", + "functions": [ + "deposit", + "withdraw", + "approve", + "balanceOf", + "transfer", + "transferFrom" + ] } ] } diff --git a/src/story_protocol_python_sdk/story_client.py b/src/story_protocol_python_sdk/story_client.py index 3b63aba..c322566 100644 --- a/src/story_protocol_python_sdk/story_client.py +++ b/src/story_protocol_python_sdk/story_client.py @@ -16,6 +16,7 @@ from story_protocol_python_sdk.resources.Permission import Permission from story_protocol_python_sdk.resources.NFTClient import NFTClient from story_protocol_python_sdk.resources.Dispute import Dispute +from story_protocol_python_sdk.resources.WIP import WIP class StoryClient: """ @@ -51,6 +52,8 @@ def __init__(self, web3, account, chain_id: int): self._permission = None self._nft_client = None self._dispute = None + self._wip = None + @property def IPAsset(self) -> IPAsset: """ @@ -126,4 +129,27 @@ def Dispute(self) -> Dispute: """ if self._dispute is None: self._dispute = Dispute(self.web3, self.account, self.chain_id) - return self._dispute \ No newline at end of file + return self._dispute + + @property + def WIP(self) -> WIP: + """ + Access the WIP resource. + + :return WIP: An instance of WIP. + """ + if self._wip is None: + self._wip = WIP(self.web3, self.account, self.chain_id) + return self._wip + + def getWalletBalance(self) -> int: + """ + Get the WIP token balance of the current wallet. + + :return int: The WIP token balance of the current wallet. + :raises ValueError: If no account is found. + """ + if not self.account or not hasattr(self.account, 'address'): + raise ValueError("No account found in wallet") + + return self.web3.eth.get_balance(self.account.address) \ No newline at end of file diff --git a/src/story_protocol_python_sdk/utils/transaction_utils.py b/src/story_protocol_python_sdk/utils/transaction_utils.py index 155f2a9..f9b075d 100644 --- a/src/story_protocol_python_sdk/utils/transaction_utils.py +++ b/src/story_protocol_python_sdk/utils/transaction_utils.py @@ -24,6 +24,10 @@ def build_and_send_transaction(web3: Web3, account, client_function, *client_arg 'nonce': web3.eth.get_transaction_count(account.address), } + # Add value if it exists in tx_options + if 'value' in tx_options: + transaction_options['value'] = tx_options['value'] + if 'gasPrice' in tx_options: transaction_options['gasPrice'] = web3.to_wei(tx_options['gasPrice'], 'gwei') if 'maxFeePerGas' in tx_options: diff --git a/tests/integration/setup_for_integration.py b/tests/integration/setup_for_integration.py index 7338d1e..648fc55 100644 --- a/tests/integration/setup_for_integration.py +++ b/tests/integration/setup_for_integration.py @@ -35,6 +35,8 @@ private_key = os.getenv('WALLET_PRIVATE_KEY') private_key_2 = os.getenv('WALLET_PRIVATE_KEY_2') rpc_url = os.getenv('RPC_PROVIDER_URL') +wallet_address = os.getenv('WALLET_ADDRESS') +wallet_address_2 = os.getenv('WALLET_ADDRESS_2') if not private_key: raise ValueError("WALLET_PRIVATE_KEY environment variable is not set") @@ -78,7 +80,9 @@ def story_client_2(): 'ARBITRATION_POLICY_UMA', 'account_2', 'story_client_2', - 'generate_cid' + 'generate_cid', 'setup_royalty_vault', - 'WIP_TOKEN_ADDRESS' + 'WIP_TOKEN_ADDRESS', + 'wallet_address', + 'wallet_address_2' ] \ No newline at end of file diff --git a/tests/integration/test_integration_wip.py b/tests/integration/test_integration_wip.py new file mode 100644 index 0000000..9261030 --- /dev/null +++ b/tests/integration/test_integration_wip.py @@ -0,0 +1,104 @@ +import pytest +from web3 import Web3 + +from setup_for_integration import ( + web3, + story_client, + story_client_2, + wallet_address, + wallet_address_2 +) + +class TestWIPDeposit: + def test_deposit(self, story_client): + """Test depositing IP to WIP""" + ip_amt = Web3.to_wei(1, 'ether') # or Web3.to_wei("0.01", 'ether') + + # Get balances before deposit + balance_before = story_client.getWalletBalance() + wip_before = story_client.WIP.balanceOf(wallet_address) + + # Deposit IP to WIP + response = story_client.WIP.deposit( + amount=ip_amt + ) + + # Verify transaction hash + assert isinstance(response['txHash'], str) + + # Get balances after deposit + balance_after = story_client.getWalletBalance() + wip_after = story_client.WIP.balanceOf(wallet_address) + + # Verify WIP balance increased by deposit amount + assert wip_after == wip_before + ip_amt + + # Calculate gas cost + tx_receipt = web3.eth.wait_for_transaction_receipt(response["txHash"], timeout=300) + gas_cost = tx_receipt['gasUsed'] * tx_receipt['effectiveGasPrice'] + + # Verify wallet balance decreased by deposit amount plus gas cost + assert balance_after == balance_before - ip_amt - gas_cost + + +class TestWIPTransfer: + def test_transfer(self, story_client): + """Test transferring WIP""" + transfer_amount = Web3.to_wei("0.01", 'ether') + + # Get balances before transfer + sender_wip_before = story_client.WIP.balanceOf(wallet_address) + receiver_wip_before = story_client.WIP.balanceOf(wallet_address_2) + + # Transfer WIP to wallet_address_2 + response = story_client.WIP.transfer( + to=wallet_address_2, + amount=transfer_amount, + tx_options={"waitForTransaction": True} + ) + + # Verify transaction hash + assert isinstance(response['txHash'], str) + + # Get balances after transfer + sender_wip_after = story_client.WIP.balanceOf(wallet_address) + receiver_wip_after = story_client.WIP.balanceOf(wallet_address_2) + + # Verify sender's WIP balance decreased by transfer amount + assert sender_wip_after == sender_wip_before - transfer_amount + + # Verify receiver's WIP balance increased by transfer amount + assert receiver_wip_after == receiver_wip_before + transfer_amount + # Note: We're not testing transferFrom here as it requires approval + # and the TypeScript test also skips this test for the same reason + + +class TestWIPWithdraw: + def test_withdraw(self, story_client): + """Test withdrawing WIP to IP""" + # Get balances before withdrawal + balance_before = story_client.getWalletBalance() + wip_before = story_client.WIP.balanceOf(wallet_address) + + # Withdraw all WIP + response = story_client.WIP.withdraw( + amount=wip_before, + tx_options={"waitForTransaction": True} + ) + + # Verify transaction hash + assert isinstance(response['txHash'], str) + + # Get balances after withdrawal + wip_after = story_client.WIP.balanceOf(wallet_address) + balance_after = story_client.getWalletBalance() + + # Verify WIP balance is now zero + assert wip_after == 0 + + # Calculate gas cost + tx_receipt = web3.eth.wait_for_transaction_receipt(response["txHash"], timeout=300) + gas_cost = tx_receipt['gasUsed'] * tx_receipt['effectiveGasPrice'] + + # Verify wallet balance increased by withdrawal amount minus gas cost + assert balance_after == balance_before + wip_before - gas_cost From 91651cc7ad3f1a355120a77bfc9fe879e7fc2ece Mon Sep 17 00:00:00 2001 From: Andrew Chung <58543094+aandrewchung@users.noreply.github.com> Date: Thu, 27 Mar 2025 02:48:54 +0900 Subject: [PATCH 13/17] [UPDATE] Fix raiseDispute() Bond Bug (#52) * Added deposit to swap into in raise_dispute() * Updated Dispute module to use camel case for better consistency --- .../resources/Dispute.py | 18 +- tests/integration/test_integration_dispute.py | 233 ++---------------- 2 files changed, 28 insertions(+), 223 deletions(-) diff --git a/src/story_protocol_python_sdk/resources/Dispute.py b/src/story_protocol_python_sdk/resources/Dispute.py index 7fbbd68..543046c 100644 --- a/src/story_protocol_python_sdk/resources/Dispute.py +++ b/src/story_protocol_python_sdk/resources/Dispute.py @@ -4,7 +4,7 @@ from story_protocol_python_sdk.utils.transaction_utils import build_and_send_transaction from story_protocol_python_sdk.utils.ipfs import convert_cid_to_hash_ipfs from eth_abi.abi import encode - +from story_protocol_python_sdk.resources.WIP import WIP ZERO_ADDRESS = "0x0000000000000000000000000000000000000000" class Dispute: @@ -22,7 +22,8 @@ def __init__(self, web3: Web3, account, chain_id: int): self.dispute_module_client = DisputeModuleClient(web3) self.arbitration_policy_uma_client = ArbitrationPolicyUMAClient(web3) - + self.WIP = WIP(web3, account, chain_id) + def _validate_address(self, address: str) -> str: """ Validates if a string is a valid Ethereum address. @@ -35,7 +36,7 @@ def _validate_address(self, address: str) -> str: raise ValueError(f"Invalid address: {address}.") return address - def raise_dispute(self, target_ip_id: str, target_tag: str, cid: str, liveness: int, bond: int, tx_options: dict = None) -> dict: + def raiseDispute(self, target_ip_id: str, target_tag: str, cid: str, liveness: int, bond: int, tx_options: dict = None) -> dict: """ Raises a dispute on a given IP ID. @@ -72,6 +73,10 @@ def raise_dispute(self, target_ip_id: str, target_tag: str, cid: str, liveness: if bond > max_bonds: raise ValueError(f"Bond must be less than {max_bonds}.") + deposit_response = self.WIP.deposit( + amount=bond + ) + # Convert CID to IPFS hash dispute_evidence_hash = convert_cid_to_hash_ipfs(cid) @@ -85,7 +90,6 @@ def raise_dispute(self, target_ip_id: str, target_tag: str, cid: str, liveness: ] ) - response = build_and_send_transaction( self.web3, self.account, @@ -107,7 +111,7 @@ def raise_dispute(self, target_ip_id: str, target_tag: str, cid: str, liveness: except Exception as e: raise ValueError(f"Failed to raise dispute: {str(e)}") - def cancel_dispute(self, dispute_id: int, data: str = "0x", tx_options: dict = None) -> dict: + def cancelDispute(self, dispute_id: int, data: str = "0x", tx_options: dict = None) -> dict: """ Cancels an ongoing dispute. @@ -133,7 +137,7 @@ def cancel_dispute(self, dispute_id: int, data: str = "0x", tx_options: dict = N except Exception as e: raise ValueError(f"Failed to cancel dispute: {str(e)}") - def resolve_dispute(self, dispute_id: int, data: str, tx_options: dict = None) -> dict: + def resolveDispute(self, dispute_id: int, data: str, tx_options: dict = None) -> dict: """ Resolves a dispute after it has been judged. @@ -159,7 +163,7 @@ def resolve_dispute(self, dispute_id: int, data: str, tx_options: dict = None) - except Exception as e: raise ValueError(f"Failed to resolve dispute: {str(e)}") - def tag_if_related_ip_infringed(self, infringement_tags: list, tx_options: dict = None) -> list: + def tagIfRelatedIpInfringed(self, infringement_tags: list, tx_options: dict = None) -> list: """ Tags a derivative if a parent has been tagged with an infringement tag. diff --git a/tests/integration/test_integration_dispute.py b/tests/integration/test_integration_dispute.py index 5538fc0..bd58b5d 100644 --- a/tests/integration/test_integration_dispute.py +++ b/tests/integration/test_integration_dispute.py @@ -8,18 +8,9 @@ account_2, story_client, story_client_2, - get_token_id, - mint_tokens, + generate_cid, approve, - getBlockTimestamp, - check_event_in_tx, - MockERC721, - MockERC20, - ZERO_ADDRESS, - ARBITRATION_POLICY_UMA, - PIL_LICENSE_TEMPLATE, - ROYALTY_POLICY, - generate_cid + wallet_address ) class TestDispute: @@ -51,112 +42,29 @@ def target_ip_id(self, story_client, story_client_2): ip_metadata=metadata_a, tx_options={ "wait_for_transaction": True} ) - return response['ipId'] - - @pytest.fixture(scope="module") - def dispute_id(self, story_client, target_ip_id): - """Create a dispute and return its ID""" - cid = generate_cid() - - response = story_client.Dispute.raise_dispute( - target_ip_id=target_ip_id, - target_tag="IMPROPER_REGISTRATION", - cid=cid, - liveness=2592000, # 30 days in seconds - bond=0, - tx_options={"wait_for_transaction": True} - ) - - assert 'txHash' in response - assert isinstance(response['txHash'], str) - assert 'disputeId' in response - assert isinstance(response['disputeId'], int) - return response['disputeId'] - @pytest.fixture(scope="module") - def parent_ip_with_license(self, story_client): - """Create a parent IP with license terms using mintAndRegisterIpAssetWithPilTerms""" - - txData = story_client.NFTClient.createNFTCollection( - name="parent-collection", - symbol="PRNT", - max_supply=100, - is_public_minting=True, - mint_open=True, - contract_uri="test-uri", - mint_fee_recipient=account.address, - ) - nft_collection = txData['nftContract'] - - response = story_client.IPAsset.mintAndRegisterIpAssetWithPilTerms( - spg_nft_contract=nft_collection, - terms=[{ - 'terms': { - 'transferable': True, - 'royalty_policy': ROYALTY_POLICY, - 'default_minting_fee': 0, - 'expiration': 0, - 'commercial_use': True, - 'commercial_attribution': False, - 'commercializer_checker': ZERO_ADDRESS, - 'commercializer_checker_data': ZERO_ADDRESS, - 'commercial_rev_share': 90, - 'commercial_rev_ceiling': 0, - 'derivatives_allowed': True, - 'derivatives_attribution': True, - 'derivatives_approval': False, - 'derivatives_reciprocal': True, - 'derivative_rev_ceiling': 0, - 'currency': MockERC20, - 'uri': "" - }, - 'licensing_config': { - 'is_set': True, - 'minting_fee': 0, - 'hook_data': "", - 'licensing_hook': ZERO_ADDRESS, - 'commercial_rev_share': 90, - 'disabled': False, - 'expect_minimum_group_reward_share': 0, - 'expect_group_reward_pool': ZERO_ADDRESS - } - }] - ) - - return { - 'ip_id': response['ipId'], - 'license_terms_id': response['licenseTermsIds'][0], - 'nft_contract': nft_collection - } - - @pytest.fixture(scope="module") - def child_ip_id(self, story_client, parent_ip_with_license): - """Create a derivative IP (child IP) from the parent IP""" - derivative_response = story_client.IPAsset.mintAndRegisterIpAndMakeDerivative( - spg_nft_contract=parent_ip_with_license['nft_contract'], - deriv_data={ - 'parent_ip_ids': [parent_ip_with_license['ip_id']], - 'license_terms_ids': [parent_ip_with_license['license_terms_id']], - 'max_minting_fee': 1, - 'max_rts': 5 * 10 ** 6, - 'max_revenue_share': 100 - }, - tx_options={"wait_for_transaction": True} - ) - - return derivative_response['ipId'] + return response['ipId'] def test_raise_dispute(self, story_client, target_ip_id): """Test raising a dispute""" cid = generate_cid() - - response = story_client.Dispute.raise_dispute( + bond_amount = 1000000000000000000 # 1 ETH in wei + + # Add approval before raising dispute + # approve( + # erc20_contract_address="0x1514000000000000000000000000000000000000", + # web3=web3, + # account=account, + # spender_address="0xfFD98c3877B8789124f02C7E8239A4b0Ef11E936", + # amount=2**256 - 1 # maximum uint256 value + # ) + + response = story_client.Dispute.raiseDispute( target_ip_id=target_ip_id, target_tag="IMPROPER_REGISTRATION", cid=cid, - liveness=2592000, # 30 days in seconds - bond=0, - tx_options={"wait_for_transaction": True} + liveness=0x278d, # 30 days in seconds + bond=bond_amount ) assert 'txHash' in response @@ -165,110 +73,3 @@ def test_raise_dispute(self, story_client, target_ip_id): assert 'disputeId' in response assert isinstance(response['disputeId'], int) assert response['disputeId'] > 0 - - def test_cancel_dispute_unauthorized(self, story_client_2, dispute_id): - """Test attempting to cancel a dispute by an unauthorized account""" - with pytest.raises(ValueError) as excinfo: - story_client_2.Dispute.cancel_dispute( - dispute_id=dispute_id, - tx_options={"wait_for_transaction": True} - ) - - assert "Failed to cancel dispute" in str(excinfo.value) - - def test_resolve_dispute_unauthorized(self, story_client_2, dispute_id): - """Test attempting to resolve a dispute by an unauthorized account""" - with pytest.raises(ValueError) as excinfo: - story_client_2.Dispute.resolve_dispute( - dispute_id=dispute_id, - data="0x", - tx_options={"wait_for_transaction": True} - ) - - assert "Failed to resolve dispute" in str(excinfo.value) - - def test_raise_dispute_invalid_tag(self, story_client, target_ip_id): - """Test raising a dispute with an invalid tag""" - cid = generate_cid() - - with pytest.raises(ValueError) as excinfo: - story_client.Dispute.raise_dispute( - target_ip_id=target_ip_id, - target_tag="INVALID_TAG", - cid=cid, - liveness=2592000, - bond=0, - tx_options={"wait_for_transaction": True} - ) - - assert "not whitelisted" in str(excinfo.value) - - def test_raise_dispute_invalid_liveness(self, story_client, target_ip_id): - """Test raising a dispute with invalid liveness period""" - min_liveness = story_client.Dispute.arbitration_policy_uma_client.minLiveness() - - cid = generate_cid() - - with pytest.raises(ValueError) as excinfo: - story_client.Dispute.raise_dispute( - target_ip_id=target_ip_id, - target_tag="IMPROPER_REGISTRATION", - cid=cid, - liveness=int(min_liveness) - 1, - bond=0, - tx_options={"wait_for_transaction": True} - ) - - assert "Liveness must be between" in str(excinfo.value) - - max_liveness = story_client.Dispute.arbitration_policy_uma_client.maxLiveness() - - with pytest.raises(ValueError) as excinfo: - story_client.Dispute.raise_dispute( - target_ip_id=target_ip_id, - target_tag="IMPROPER_REGISTRATION", - cid=cid, - liveness=int(max_liveness) + 1, - bond=0, - tx_options={"wait_for_transaction": True} - ) - - assert "Liveness must be between" in str(excinfo.value) - - def test_raise_dispute_invalid_bond(self, story_client, target_ip_id): - """Test raising a dispute with an excessive bond amount""" - cid = generate_cid() - - max_bonds = story_client.Dispute.arbitration_policy_uma_client.maxBonds( - token=web3.to_checksum_address("0x1514000000000000000000000000000000000000") - ) - - with pytest.raises(ValueError) as excinfo: - story_client.Dispute.raise_dispute( - target_ip_id=target_ip_id, - target_tag="IMPROPER_REGISTRATION", - cid=cid, - liveness=2592000, - bond=int(max_bonds) + 1, - tx_options={"wait_for_transaction": True} - ) - - assert "Bond must be less than" in str(excinfo.value) - - def test_raise_dispute_invalid_address(self, story_client): - """Test raising a dispute with an invalid IP address""" - cid = generate_cid() - - with pytest.raises(ValueError) as excinfo: - story_client.Dispute.raise_dispute( - target_ip_id="not-an-address", - target_tag="IMPROPER_REGISTRATION", - cid=cid, - liveness=2592000, - bond=0, - tx_options={"wait_for_transaction": True} - ) - - assert "Invalid address" in str(excinfo.value) - - \ No newline at end of file From 8fc5a7bb98ae087bd478d97a3f9b29b44657e0e7 Mon Sep 17 00:00:00 2001 From: bpolania Date: Wed, 26 Mar 2025 14:55:56 -0700 Subject: [PATCH 14/17] Fixed version --- .DS_Store | Bin 0 -> 6148 bytes setup.py | 4 ++-- src/.DS_Store | Bin 0 -> 6148 bytes src/story_protocol_python_sdk/.DS_Store | Bin 0 -> 6148 bytes src/story_protocol_python_sdk/__init__.py | 11 ++++++++++- 5 files changed, 12 insertions(+), 3 deletions(-) create mode 100644 .DS_Store create mode 100644 src/.DS_Store create mode 100644 src/story_protocol_python_sdk/.DS_Store diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..2ddc4dfcdcbc79c4e08cbf8b70ac4ac7a99060da GIT binary patch literal 6148 zcmeHK%}T>S5T0#Qn_7e%6!f;>wP0haB3?qRFJMFuDm5`hgE3p0)E-J9cYPsW#OHDL zr_vw2RPd+l!0fj>yI;b5VRthCK$`u~2Cx9YMCWL%V)KdddG2#oP>~fxBAyWjgR~pO zESOuX16l#Cz~57V@7)pv(1!?OnB2dwK{t-ltX_X3g`!bfDVt@pYF>NyY~W>n)=!&$ z?~;$s7>k2(-w)2C&Y*3rAG0L$qomUlgedIblBXqOp@bou5kec)CMxE3ve|> zE1(rvZUy-HK+!q63Ui6#=)gvl0Eh;Lg<+r95}ZR7x(aiNID+?f85umIHAm>Q*bD6_{3_D2Eom|4+WI|EEcMq!rK#ER_Pp zXnCzBrljuHx#aj=OVMx8x$$+mL_xtuUB{xsui{N~VK_&52hmlSOT-A8{t*x|=te8> HqYAtO$o#^R literal 0 HcmV?d00001 diff --git a/setup.py b/setup.py index b0b9056..e4947a1 100644 --- a/setup.py +++ b/setup.py @@ -5,8 +5,8 @@ setup( name='story_protocol_python_sdk', - version='0.3.7', - packages=find_packages(where='src', exclude=["tests"]), + version='0.3.12b1', + packages=find_packages(where='src', include=['story_protocol_python_sdk*']), package_dir={'': 'src'}, install_requires=[ 'web3>=7.0.0', diff --git a/src/.DS_Store b/src/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..5bfb446ec1886902d7351b744ad9c958d2b3056a GIT binary patch literal 6148 zcmeHK&1%~~5T141M8*_+NT9h1ay2xL-9pJlxXBAtB{`(UwIUM>t6F2p5ylvFPhKc5 z(&yR&;^(F{}($PBBfQGM`4Nj*$HjP17VV zj*ow-+D`q=?w+^jHNEfQSeIcjEarKCIJ;u&tJZ0>t`DP&WK<4X`=51I43lg$GYLsN zLddtvB#U*~*YhmSO>Sr#yoT2pv<{cc?uSopzuP-mwf$wM(}B_Jeq5~@-oerP(=UTb z`ZLpamT!e$)5u$gYq&?{FQ$GQ&azZzH^^T1yzWs@7!U@8fmgzS+rGT!EBRjeOkqG6 zcsT}mfAFD(9e&@%1r95V zHl36i8U47Cm3yHmGdldf2`3d;lu{TF1~wVkv1W_U|MTbH|C>o-2?N5wf5m{RpM__A zOv# literal 0 HcmV?d00001 diff --git a/src/story_protocol_python_sdk/.DS_Store b/src/story_protocol_python_sdk/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..ddd77908383b334d6cc621c911031bc369e349d8 GIT binary patch literal 6148 zcmeH~JqiLr422VaK(Mj2oW=uqgF)64cmYuxVZlP}=jgutAh=qK$O|NICX*=p6+0Ud z(ars|7FmeM3~nkb3j3ZxBmrL(eqpYVnwOF)Yypf$CHA_LQCg+{CT z7-D&EhnB3XsVy|xMRWMj{AaZ(2By(2TCjm>bzvX@5*QE|$9`e={}%pf{vWh(O9CYD zX9RT8?Ya#fD(}{}$FuxCv$k$ Date: Wed, 26 Mar 2025 15:08:24 -0700 Subject: [PATCH 15/17] Update .DS_Store --- .DS_Store | Bin 6148 -> 6148 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/.DS_Store b/.DS_Store index 2ddc4dfcdcbc79c4e08cbf8b70ac4ac7a99060da..feca9dcef95d23822e8eb9bf341585b2e54d112c 100644 GIT binary patch delta 48 zcmZoMXffCzz{ Date: Wed, 26 Mar 2025 15:27:41 -0700 Subject: [PATCH 16/17] Version update --- setup.py | 2 +- src/story_protocol_python_sdk/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index b0b9056..740148b 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ setup( name='story_protocol_python_sdk', - version='0.3.7', + version='0.3.12rc1', packages=find_packages(where='src', exclude=["tests"]), package_dir={'': 'src'}, install_requires=[ diff --git a/src/story_protocol_python_sdk/__init__.py b/src/story_protocol_python_sdk/__init__.py index 81899f4..5540fdf 100644 --- a/src/story_protocol_python_sdk/__init__.py +++ b/src/story_protocol_python_sdk/__init__.py @@ -1,4 +1,4 @@ -__version__ = "0.3.12b1" +__version__ = "0.3.12rc1" from .story_client import StoryClient from .resources.IPAsset import IPAsset From 1f410cf165c8c2ff596b0e6d66438dd49a6fdb3a Mon Sep 17 00:00:00 2001 From: bpolania Date: Wed, 26 Mar 2025 16:21:17 -0700 Subject: [PATCH 17/17] minor fixes and .gitignore update --- .gitignore | 4 +++- src/story_protocol_python_sdk/__init__.py | 2 -- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index efbdec4..c7e4265 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.DS_Store .pytest_cache latest_logs/ @@ -25,6 +26,7 @@ var/ *.egg-info/ .installed.cfg *.egg + # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. @@ -103,4 +105,4 @@ celerybeat.pid # Environments .env -web3py.md \ No newline at end of file +web3py.md diff --git a/src/story_protocol_python_sdk/__init__.py b/src/story_protocol_python_sdk/__init__.py index 5540fdf..7aaf02c 100644 --- a/src/story_protocol_python_sdk/__init__.py +++ b/src/story_protocol_python_sdk/__init__.py @@ -6,8 +6,6 @@ from .resources.Royalty import Royalty from .resources.IPAccount import IPAccount from .resources.Dispute import Dispute -from .resources.NFTClient import NFTClient -from .resources.Permission import Permission from .resources.WIP import WIP