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 3d412be..2b868d9 100644 --- a/src/story_protocol_python_sdk/abi/IPAccountImpl/IPAccountImpl_client.py +++ b/src/story_protocol_python_sdk/abi/IPAccountImpl/IPAccountImpl_client.py @@ -27,6 +27,14 @@ def build_execute_transaction(self, to, value, data, operation, tx_params): # return self.contract.functions.execute(to, value, data).build_transaction(tx_params) + def executeBatch(self, calls, operation): + + return self.contract.functions.executeBatch(calls, operation).transact() + + def build_executeBatch_transaction(self, calls, operation, tx_params): + return self.contract.functions.executeBatch(calls, operation).build_transaction(tx_params) + + def executeWithSig(self, to, value, data, signer, deadline, signature): return self.contract.functions.executeWithSig(to, value, data, signer, deadline, signature).transact() diff --git a/src/story_protocol_python_sdk/resources/IPAccount.py b/src/story_protocol_python_sdk/resources/IPAccount.py index 2179a60..cea4bd6 100644 --- a/src/story_protocol_python_sdk/resources/IPAccount.py +++ b/src/story_protocol_python_sdk/resources/IPAccount.py @@ -1,11 +1,12 @@ """Module for handling IP Account operations and transactions.""" from web3 import Web3 -from web3.exceptions import InvalidAddress from story_protocol_python_sdk.abi.IPAccountImpl.IPAccountImpl_client import IPAccountImplClient from story_protocol_python_sdk.abi.IPAssetRegistry.IPAssetRegistry_client import IPAssetRegistryClient from story_protocol_python_sdk.abi.AccessController.AccessController_client import AccessControllerClient +from story_protocol_python_sdk.abi.CoreMetadataModule.CoreMetadataModule_client import CoreMetadataModuleClient +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,6 +26,8 @@ def __init__(self, web3: Web3, account, chain_id: int): self.ip_asset_registry_client = IPAssetRegistryClient(web3) self.access_controller_client = AccessControllerClient(web3) self.ip_account_client = IPAccountImplClient(web3) + self.core_metadata_module_client = CoreMetadataModuleClient(web3) + self.mock_erc20_client = MockERC20Client(web3) def getToken(self, ip_id: str) -> dict: """Retrieve token information associated with an IP account. @@ -153,4 +156,86 @@ def owner(self, ip_id: str) -> str: 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 + raise e + + def setIpMetadata(self, ip_id: str, metadata_uri: str, metadata_hash: str, tx_options: dict = None) -> dict: + """Sets the metadataURI for an IP asset. + + :param ip_id str: The IP ID to set metadata for. + :param metadata_uri str: The metadata URI to set. + :param metadata_hash str: The metadata hash. + :param tx_options dict: [Optional] The transaction options. + :returns dict: A dictionary with the transaction hash. + :raises ValueError: If the IP ID is invalid or not registered. + """ + try: + if not self._is_registered(ip_id): + raise ValueError(f"IP id {ip_id} is not registered") + + data = self.core_metadata_module_client.contract.encode_abi( + abi_element_identifier="setMetadataURI", + args=[Web3.to_checksum_address(ip_id), metadata_uri, metadata_hash] + ) + + response = self.execute( + to=self.core_metadata_module_client.contract.address, + value=0, + ip_id=ip_id, + data=data, + tx_options=tx_options + ) + + return response + except Exception as e: + raise e + + def transferERC20(self, ip_id: str, tokens: list, tx_options: dict = None) -> dict: + """Transfers ERC20 tokens from the IP Account to the target address. + + :param ip_id str: The IP ID to transfer tokens from. + :param tokens list: A list of dictionaries containing token transfer details. + Each dictionary should have 'address' (token contract address), + 'target' (recipient address), and 'amount' (token amount). + :param tx_options dict: [Optional] The transaction options. + :returns dict: A dictionary with the transaction hash. + :raises ValueError: If the IP ID is invalid or not registered, or if token parameters are invalid. + """ + try: + if not self._is_registered(ip_id): + raise ValueError(f"IP id {ip_id} is not registered") + + ip_account = IPAccountImplClient(self.web3, contract_address=ip_id) + + for token in tokens: + if not all(key in token for key in ['address', 'target', 'amount']): + raise ValueError("Each token transfer must include 'address', 'target', and 'amount'") + + calls = [] + for token in tokens: + token_address = self.web3.to_checksum_address(token['address']) + target_address = self.web3.to_checksum_address(token['target']) + amount = int(token['amount']) + + data = self.mock_erc20_client.contract.encode_abi( + abi_element_identifier="transfer", + args=[target_address, amount] + ) + + calls.append({ + 'target': token_address, + 'data': data, + 'value': 0 + }) + + response = build_and_send_transaction( + self.web3, + self.account, + ip_account.build_executeBatch_transaction, + calls, + 0, + tx_options=tx_options + ) + + return response + except Exception as e: + raise ValueError(f"Failed to transfer ERC20: {str(e)}") diff --git a/src/story_protocol_python_sdk/scripts/config.json b/src/story_protocol_python_sdk/scripts/config.json index 9b1b5fd..e20f8af 100644 --- a/src/story_protocol_python_sdk/scripts/config.json +++ b/src/story_protocol_python_sdk/scripts/config.json @@ -30,6 +30,7 @@ "contract_address": "0xc93d49fEdED1A2fbE3B54223Df65f4edB3845eb0", "functions": [ "execute", + "executeBatch", "executeWithSig", "state", "token", diff --git a/tests/integration/setup_for_integration.py b/tests/integration/setup_for_integration.py index 648fc55..ce49301 100644 --- a/tests/integration/setup_for_integration.py +++ b/tests/integration/setup_for_integration.py @@ -84,5 +84,7 @@ def story_client_2(): 'setup_royalty_vault', 'WIP_TOKEN_ADDRESS', 'wallet_address', - 'wallet_address_2' + 'wallet_address_2', + 'private_key', + 'private_key_2' ] \ No newline at end of file diff --git a/tests/integration/test_integration_ip_account.py b/tests/integration/test_integration_ip_account.py index bcfcae1..a1ec375 100644 --- a/tests/integration/test_integration_ip_account.py +++ b/tests/integration/test_integration_ip_account.py @@ -1,36 +1,22 @@ # tests/integration/test_integration_ip_account.py -import os, json, sys import pytest -from dotenv import load_dotenv from web3 import Web3 from eth_account import Account from eth_account.messages import encode_typed_data from eth_abi.abi import encode -# 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_token_id, get_story_client_in_devnet, MockERC721, getBlockTimestamp - -load_dotenv(override=True) -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 -def story_client(): - return get_story_client_in_devnet(web3, account) +from setup_for_integration import ( + web3, + account, + story_client, + get_token_id, + mint_tokens, + getBlockTimestamp, + MockERC721, + MockERC20, + private_key +) class TestBasicIPAccountOperations: """Basic IP Account operations like execute and nonce retrieval""" @@ -333,4 +319,116 @@ def test_execute_unregistered_ip(self, story_client): data=data ) - assert "is not registered" in str(exc_info.value) \ No newline at end of file + assert "is not registered" in str(exc_info.value) + +class TestSetIpMetadata: + """Tests for setting IP metadata""" + + def test_set_ip_metadata(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 + ) + + response = story_client.IPAccount.setIpMetadata( + ip_id=response['ipId'], + metadata_uri="https://example.com", + metadata_hash=web3.to_hex(web3.keccak(text="test-metadata-hash")) + ) + + assert response is not None, "Response is None, indicating the contract interaction failed." + assert 'txHash' in response, "Response does not contain 'txHash'." + assert response['txHash'] is not None, "'txHash' is None." + assert isinstance(response['txHash'], str), "'txHash' is not a string." + assert len(response['txHash']) > 0, "'txHash' is empty." + +class TestTransferERC20: + """Tests for transferring ERC20 tokens""" + + def test_transfer_erc20(self, story_client): + """Test transferring ERC20 tokens""" + token_id = get_token_id(MockERC721, story_client.web3, story_client.account) + response = story_client.IPAsset.register( + nft_contract=MockERC721, + token_id=token_id + ) + ip_id = response['ipId'] + + # 1. Query token balance of ipId and wallet before + initial_erc20_balance_of_ip_id = story_client.Royalty.mock_erc20_client.balanceOf( + account=ip_id + ) + initial_erc20_balance_of_wallet = story_client.Royalty.mock_erc20_client.balanceOf( + account=story_client.account.address + ) + initial_wip_balance_of_ip_id = story_client.WIP.balanceOf( + address=ip_id + ) + initial_wip_balance_of_wallet = story_client.WIP.balanceOf( + address=story_client.account.address + ) + + # 2. Transfer ERC20 tokens to the IP account + amount_to_mint = 2000000 # Equivalent to 0.002 ether in wei + mint_receipt = mint_tokens( + erc20_contract_address=MockERC20, + web3=story_client.web3, + account=story_client.account, + to_address=ip_id, + amount=amount_to_mint + ) + + # 3. Transfer WIP to the IP account + # First deposit (wrap) IP to WIP + deposit_response = story_client.WIP.deposit( + amount=1 + ) + + # Then transfer WIP to the IP account + response = story_client.WIP.transfer( + to=ip_id, + amount=1 + ) + + # 4. Transfer tokens from IP account to wallet address + response = story_client.IPAccount.transferERC20( + ip_id=ip_id, + tokens=[ + { + "address": story_client.WIP.wip_client.contract.address, + "target": story_client.account.address, + "amount": 1 + }, + { + "address": MockERC20, + "target": story_client.account.address, + "amount": 1000000 # Equivalent to 0.001 ether + }, + { + "address": MockERC20, + "target": story_client.account.address, + "amount": 1000000 # Equivalent to 0.001 ether + } + ] + ) + + # 5. Query token balance of ipId and wallet address after transfer + final_erc20_balance_of_ip_id = story_client.Royalty.mock_erc20_client.balanceOf( + account=ip_id + ) + final_wip_balance_of_ip_id = story_client.WIP.balanceOf( + address=ip_id + ) + final_erc20_balance_of_wallet = story_client.Royalty.mock_erc20_client.balanceOf( + account=story_client.account.address + ) + final_wip_balance_of_wallet = story_client.WIP.balanceOf( + address=story_client.account.address + ) + + assert isinstance(response['txHash'], str) and response['txHash'] != "" + assert final_erc20_balance_of_ip_id == initial_erc20_balance_of_ip_id + assert final_wip_balance_of_ip_id == initial_wip_balance_of_ip_id + assert final_erc20_balance_of_wallet == initial_erc20_balance_of_wallet + 2000000 + assert final_wip_balance_of_wallet == initial_wip_balance_of_wallet + 1 diff --git a/tests/integration/utils.py b/tests/integration/utils.py index 0de02e0..81b0bdf 100644 --- a/tests/integration/utils.py +++ b/tests/integration/utils.py @@ -196,7 +196,6 @@ def generate_cid() -> str: # 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)