diff --git a/src/story_protocol_python_sdk/abi/ArbitrationPolicyUMA/ArbitrationPolicyUMA_client.py b/src/story_protocol_python_sdk/abi/ArbitrationPolicyUMA/ArbitrationPolicyUMA_client.py index 35dd083..6b885a3 100644 --- a/src/story_protocol_python_sdk/abi/ArbitrationPolicyUMA/ArbitrationPolicyUMA_client.py +++ b/src/story_protocol_python_sdk/abi/ArbitrationPolicyUMA/ArbitrationPolicyUMA_client.py @@ -22,6 +22,9 @@ def __init__(self, web3: Web3): abi = json.load(abi_file) self.contract = self.web3.eth.contract(address=contract_address, abi=abi) + def disputeIdToAssertionId(self, disputeId): + return self.contract.functions.disputeIdToAssertionId(disputeId).call() + def maxBonds(self, token): return self.contract.functions.maxBonds(token).call() @@ -30,5 +33,8 @@ def maxLiveness(self): def minLiveness(self): return self.contract.functions.minLiveness().call() + + def oov3(self): + return self.contract.functions.oov3().call() \ No newline at end of file diff --git a/src/story_protocol_python_sdk/abi/WIP/WIP_client.py b/src/story_protocol_python_sdk/abi/WIP/WIP_client.py index 2d10b9c..9fbe6c1 100644 --- a/src/story_protocol_python_sdk/abi/WIP/WIP_client.py +++ b/src/story_protocol_python_sdk/abi/WIP/WIP_client.py @@ -52,6 +52,9 @@ def withdraw(self, value): def build_withdraw_transaction(self, value, tx_params): return self.contract.functions.withdraw(value).build_transaction(tx_params) + def allowance(self, owner, spender): + return self.contract.functions.allowance(owner, spender).call() + def balanceOf(self, owner): return self.contract.functions.balanceOf(owner).call() diff --git a/src/story_protocol_python_sdk/abi/jsons/ASSERTION_ABI.json b/src/story_protocol_python_sdk/abi/jsons/ASSERTION_ABI.json new file mode 100644 index 0000000..11af2af --- /dev/null +++ b/src/story_protocol_python_sdk/abi/jsons/ASSERTION_ABI.json @@ -0,0 +1,142 @@ +[ + { + "inputs": [ + { + "internalType": "bytes32", + "name": "assertionId", + "type": "bytes32" + } + ], + "name": "getAssertion", + "outputs": [ + { + "components": [ + { + "components": [ + { + "internalType": "bool", + "name": "arbitrateViaEscalationManager", + "type": "bool" + }, + { + "internalType": "bool", + "name": "discardOracle", + "type": "bool" + }, + { + "internalType": "bool", + "name": "validateDisputers", + "type": "bool" + }, + { + "internalType": "address", + "name": "assertingCaller", + "type": "address" + }, + { + "internalType": "address", + "name": "escalationManager", + "type": "address" + } + ], + "internalType": "struct OptimisticOracleV3Interface.EscalationManagerSettings", + "name": "escalationManagerSettings", + "type": "tuple" + }, + { + "internalType": "address", + "name": "asserter", + "type": "address" + }, + { + "internalType": "uint64", + "name": "assertionTime", + "type": "uint64" + }, + { + "internalType": "bool", + "name": "settled", + "type": "bool" + }, + { + "internalType": "contract IERC20", + "name": "currency", + "type": "address" + }, + { + "internalType": "uint64", + "name": "expirationTime", + "type": "uint64" + }, + { + "internalType": "bool", + "name": "settlementResolution", + "type": "bool" + }, + { + "internalType": "bytes32", + "name": "domainId", + "type": "bytes32" + }, + { + "internalType": "bytes32", + "name": "identifier", + "type": "bytes32" + }, + { + "internalType": "uint256", + "name": "bond", + "type": "uint256" + }, + { + "internalType": "address", + "name": "callbackRecipient", + "type": "address" + }, + { + "internalType": "address", + "name": "disputer", + "type": "address" + } + ], + "internalType": "struct OptimisticOracleV3Interface.Assertion", + "name": "", + "type": "tuple" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "assertionId", + "type": "bytes32" + } + ], + "name": "settleAssertion", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "currency", + "type": "address" + } + ], + "name": "getMinimumBond", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + } +] diff --git a/src/story_protocol_python_sdk/resources/Dispute.py b/src/story_protocol_python_sdk/resources/Dispute.py index 6e7fd49..b232419 100644 --- a/src/story_protocol_python_sdk/resources/Dispute.py +++ b/src/story_protocol_python_sdk/resources/Dispute.py @@ -4,10 +4,13 @@ from story_protocol_python_sdk.resources.WIP import WIP 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.abi.IPAccountImpl.IPAccountImpl_client import IPAccountImplClient +from story_protocol_python_sdk.abi.WIP.WIP_client import WIPClient 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 story_protocol_python_sdk.utils.constants import ZERO_ADDRESS +from story_protocol_python_sdk.utils.oov3 import get_assertion_bond class Dispute: """ @@ -25,7 +28,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) - + self.wip_client = WIPClient(web3) + def _validate_address(self, address: str) -> str: """ Validates if a string is a valid Ethereum address. @@ -214,3 +218,140 @@ def _parse_tx_dispute_raised_event(self, tx_receipt: dict) -> int: dispute_id = int.from_bytes(data[:32], byteorder='big') return dispute_id return None + + def dispute_assertion(self, assertion_id: str, counter_evidence_cid: str, ip_id: str, tx_options: dict = None) -> dict: + """ + Counters a dispute that was raised by another party on an IP using counter evidence. + The counter evidence (e.g., documents, images) should be uploaded to IPFS, + and its corresponding CID is converted to a hash for the request. + + The liveness period is split in two parts: + - the first part of the liveness period in which only the IP's owner can be called the method. + - a second part in which any address can be called the method. + + If you only have a disputeId, call dispute_id_to_assertion_id to get the assertionId needed here. + + :param assertion_id str: The ID of the assertion to dispute. + :param counter_evidence_cid str: The IPFS CID of the counter evidence. + :param ip_id str: The IP ID related to the dispute. + :param tx_options dict: [Optional] Transaction options. + :return dict: Transaction response containing tx_hash and receipt. + """ + try: + # Validate IP ID + ip_id = self._validate_address(ip_id) + + # Create IP Account client + ip_account = IPAccountImplClient(self.web3, contract_address=ip_id) + + # Get assertion details to determine bond amount + bond = get_assertion_bond(self.web3, self.arbitration_policy_uma_client, assertion_id) + + # Check if user has enough WIP tokens + user_balance = self.wip.balance_of(address=self.account.address) + + if user_balance < bond: + raise ValueError(f"Insufficient WIP balance. Required: {bond}, Available: {user_balance}") + + # Convert CID to IPFS hash + counter_evidence_hash = convert_cid_to_hash_ipfs(counter_evidence_cid) + + # Get encoded data for dispute assertion + encoded_data = self.arbitration_policy_uma_client.contract.encode_abi( + abi_element_identifier="disputeAssertion", + args= [ + assertion_id, + counter_evidence_hash + ] + ) + + # Check allowance + allowance = self.wip.allowance( + owner=self.account.address, + spender=ip_account.contract.address + ) + + # Approve IP Account to transfer WrappedIP tokens if needed + if allowance < bond: + approve_tx = self.wip.approve( + spender=ip_account.contract.address, + amount=2**256 - 1 # maxUint256 + ) + + # Prepare calls for executeBatch + calls = [] + + if bond > 0: + # Transfer tokens from wallet to IP Account + transfer_data = self.wip_client.contract.encode_abi( + abi_element_identifier="transferFrom", + args=[ + self.account.address, + ip_account.contract.address, + bond + ] + ) + calls.append({ + 'target': self.wip_client.contract.address, + 'value': 0, + 'data': transfer_data + }) + + # Approve arbitration policy to spend tokens + approve_data = self.wip_client.contract.encode_abi( + abi_element_identifier="approve", + args=[ + self.arbitration_policy_uma_client.contract.address, + 2**256 - 1 # maxUint256 + ] + ) + calls.append({ + 'target': self.wip_client.contract.address, + 'value': 0, + 'data': approve_data + }) + + # Add dispute assertion call + calls.append({ + 'target': self.arbitration_policy_uma_client.contract.address, + 'value': 0, + 'data': encoded_data + }) + + # Execute batch transaction + response = build_and_send_transaction( + self.web3, + self.account, + ip_account.build_executeBatch_transaction, + calls, + 0, + tx_options=tx_options + ) + + return { + 'tx_hash': response['tx_hash'], + 'receipt': response.get('tx_receipt') + } + + except Exception as e: + raise ValueError(f"Failed to dispute assertion: {str(e)}") + + def dispute_id_to_assertion_id(self, dispute_id: int) -> str: + """ + Converts a dispute ID to its corresponding assertion ID. + + :param dispute_id int: The dispute ID to convert. + :return str: The corresponding assertion ID as a hex string. + :raises ValueError: If there is an error during the conversion. + """ + try: + assertion_id = self.arbitration_policy_uma_client.disputeIdToAssertionId(dispute_id) + return assertion_id + except Exception as e: + raise ValueError(f"Failed to convert dispute ID to assertion ID: {str(e)}") + + def get_assertion_bond(self, assertion_id: str) -> int: + """ + Get the bond amount for a given assertion ID. + """ + return get_assertion_bond(self.web3, self.arbitration_policy_uma_client, assertion_id) \ 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 index 2b589c6..94319eb 100644 --- a/src/story_protocol_python_sdk/resources/WIP.py +++ b/src/story_protocol_python_sdk/resources/WIP.py @@ -196,3 +196,26 @@ def transfer_from(self, from_address: str, to: str, amount: int, tx_options: dic except Exception as e: raise ValueError(f"Failed to transfer WIP from another address: {str(e)}") + + def allowance(self, owner: str, spender: str) -> int: + """ + Returns the amount of WIP tokens that `spender` is allowed to spend on behalf of `owner`. + + :param owner str: The address of the token owner. + :param spender str: The address of the spender. + :return int: The amount of WIP tokens the spender is allowed to spend. + """ + try: + if not self.web3.is_address(owner): + raise ValueError(f"The owner address {owner} is not valid.") + + if not self.web3.is_address(spender): + raise ValueError(f"The spender address {spender} is not valid.") + + owner = self.web3.to_checksum_address(owner) + spender = self.web3.to_checksum_address(spender) + + return self.wip_client.allowance(owner, spender) + + except Exception as e: + raise ValueError(f"Failed to get allowance: {str(e)}") diff --git a/src/story_protocol_python_sdk/scripts/config.json b/src/story_protocol_python_sdk/scripts/config.json index cfb0e4d..746b31b 100644 --- a/src/story_protocol_python_sdk/scripts/config.json +++ b/src/story_protocol_python_sdk/scripts/config.json @@ -219,7 +219,9 @@ "functions": [ "minLiveness", "maxLiveness", - "maxBonds" + "maxBonds", + "disputeIdToAssertionId", + "oov3" ] }, { @@ -239,7 +241,8 @@ "approve", "balanceOf", "transfer", - "transferFrom" + "transferFrom", + "allowance" ] }, { diff --git a/src/story_protocol_python_sdk/scripts/generate_clients.py b/src/story_protocol_python_sdk/scripts/generate_clients.py index 39b2d84..182f308 100644 --- a/src/story_protocol_python_sdk/scripts/generate_clients.py +++ b/src/story_protocol_python_sdk/scripts/generate_clients.py @@ -91,9 +91,17 @@ def generate_python_class_from_abi(abi, contract_name, functions, output_dir): else: function_name_counts[item['name']] = 1 + # Process inputs, replacing 'from' with 'from_address' + inputs = [] + for input_param in item['inputs']: + param_name = input_param['name'] + if param_name == 'from': + param_name = 'from_address' + inputs.append(param_name) + function = { 'name': item['name'], - 'inputs': [input['name'] for input in item['inputs']], + 'inputs': inputs, 'stateMutability': item.get('stateMutability', 'nonpayable') } selected_functions.append(function) diff --git a/src/story_protocol_python_sdk/utils/oov3.py b/src/story_protocol_python_sdk/utils/oov3.py new file mode 100644 index 0000000..1ff9b3e --- /dev/null +++ b/src/story_protocol_python_sdk/utils/oov3.py @@ -0,0 +1,39 @@ +# Define the ABI for the getAssertion function +# Load the ABI from the JSON file +import json +import os +from web3 import Web3 +from story_protocol_python_sdk.abi.ArbitrationPolicyUMA.ArbitrationPolicyUMA_client import ArbitrationPolicyUMAClient + +abi_path = os.path.join(os.path.dirname(__file__), '..', 'abi', 'jsons', 'ASSERTION_ABI.json') +with open(abi_path, 'r') as abi_file: + ASSERTION_ABI = json.load(abi_file) + +def get_oov3_contract(arbitration_policy_uma_client: ArbitrationPolicyUMAClient) -> str: + """ + Get the OOv3 contract address. + + :param arbitration_policy_uma_client: The ArbitrationPolicyUMA client instance. + :return str: The OOv3 contract address. + """ + return arbitration_policy_uma_client.oov3() + +def get_assertion_bond(web3: Web3, arbitration_policy_uma_client: ArbitrationPolicyUMAClient, assertion_id: str) -> int: + """ + Get assertion details to determine bond amount. + + :param web3: The Web3 instance. + :param arbitration_policy_uma_client: The ArbitrationPolicyUMA client instance. + :param assertion_id str: The ID of the assertion. + :return int: The bond amount. + """ + try: + oov3_contract_address = get_oov3_contract(arbitration_policy_uma_client) + + oov3_contract = web3.eth.contract(address=oov3_contract_address, abi=ASSERTION_ABI) + + assertion_data = oov3_contract.functions.getAssertion(assertion_id).call() + + return assertion_data[9] + except Exception as e: + raise ValueError(f"Failed to get assertion details: {str(e)}") \ No newline at end of file diff --git a/tests/integration/test_integration_dispute.py b/tests/integration/test_integration_dispute.py index 98a4ea4..9fa63db 100644 --- a/tests/integration/test_integration_dispute.py +++ b/tests/integration/test_integration_dispute.py @@ -39,25 +39,15 @@ def target_ip_id(self, story_client, story_client_2): response = story_client_2.IPAsset.mint_and_register_ip( spg_nft_contract=nft_contract, - ip_metadata=metadata_a, - tx_options={ "wait_for_transaction": True} + ip_metadata=metadata_a ) return response['ip_id'] - def test_raise_dispute(self, story_client, target_ip_id): - """Test raising a dispute""" + @pytest.fixture(scope="module") + def dispute_id(self, story_client, target_ip_id): cid = generate_cid() 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.raise_dispute( target_ip_id=target_ip_id, @@ -73,3 +63,33 @@ def test_raise_dispute(self, story_client, target_ip_id): assert 'dispute_id' in response assert isinstance(response['dispute_id'], int) assert response['dispute_id'] > 0 + + return response['dispute_id'] + + def test_raise_dispute(self, story_client, dispute_id): + """Test raising a dispute""" + assert dispute_id is not None + + def test_counter_dispute(self, story_client_2, story_client, target_ip_id, dispute_id): + """Test countering a dispute""" + # Get the assertion ID from the dispute ID + assertion_id = story_client_2.Dispute.dispute_id_to_assertion_id(dispute_id) + + # Generate a CID for counter evidence + counter_evidence_cid = generate_cid() + + deposit_response_2 = story_client_2.WIP.deposit( + amount=Web3.to_wei(1, 'ether') #1 IP + ) + + # Counter the dispute assertion with story_client_2 (the IP owner) + response = story_client_2.Dispute.dispute_assertion( + ip_id=target_ip_id, + assertion_id=assertion_id, + counter_evidence_cid=counter_evidence_cid + ) + + # Verify the response + assert 'tx_hash' in response + assert isinstance(response['tx_hash'], str) + assert len(response['tx_hash']) > 0