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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
89 changes: 87 additions & 2 deletions src/story_protocol_python_sdk/resources/IPAccount.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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.
Expand Down Expand Up @@ -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
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):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for future reference, i think we can actually skip this check (its something we are looking to do in tsdk as well) if the poc call later handles this error already.

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)}")
1 change: 1 addition & 0 deletions src/story_protocol_python_sdk/scripts/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"contract_address": "0xc93d49fEdED1A2fbE3B54223Df65f4edB3845eb0",
"functions": [
"execute",
"executeBatch",
"executeWithSig",
"state",
"token",
Expand Down
4 changes: 3 additions & 1 deletion tests/integration/setup_for_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
]
150 changes: 124 additions & 26 deletions tests/integration/test_integration_ip_account.py
Original file line number Diff line number Diff line change
@@ -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"""
Expand Down Expand Up @@ -333,4 +319,116 @@ def test_execute_unregistered_ip(self, story_client):
data=data
)

assert "is not registered" in str(exc_info.value)
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
1 change: 0 additions & 1 deletion tests/integration/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down