diff --git a/raiden/network/blockchain_service.py b/raiden/network/blockchain_service.py index b18a9a78da..684f7ea338 100644 --- a/raiden/network/blockchain_service.py +++ b/raiden/network/blockchain_service.py @@ -13,7 +13,13 @@ ) from raiden.network.rpc.client import JSONRPCClient from raiden.utils import privatekey_to_address -from raiden.utils.typing import Address, ChannelID, T_ChannelID, TokenNetworkAddress +from raiden.utils.typing import ( + Address, + ChannelID, + PaymentNetworkID, + T_ChannelID, + TokenNetworkAddress, +) from raiden_contracts.contract_manager import ContractManager @@ -143,7 +149,7 @@ def token_network_registry(self, address: Address) -> TokenNetworkRegistry: if address not in self.address_to_token_network_registry: self.address_to_token_network_registry[address] = TokenNetworkRegistry( jsonrpc_client=self.client, - registry_address=address, + registry_address=PaymentNetworkID(address), contract_manager=self.contract_manager, ) diff --git a/raiden/network/proxies/payment_channel.py b/raiden/network/proxies/payment_channel.py index 563b1b5575..aa221bf3d3 100644 --- a/raiden/network/proxies/payment_channel.py +++ b/raiden/network/proxies/payment_channel.py @@ -6,8 +6,18 @@ from raiden.constants import UINT256_MAX from raiden.network.proxies import TokenNetwork from raiden.network.proxies.token_network import ChannelDetails -from raiden.utils import typing from raiden.utils.filters import decode_event, get_filter_args_for_specific_event_from_channel +from raiden.utils.typing import ( + AdditionalHash, + Address, + BalanceHash, + BlockSpecification, + ChannelID, + Locksroot, + Nonce, + Signature, + TokenAmount, +) from raiden_contracts.constants import CONTRACT_TOKEN_NETWORK, ChannelEvent from raiden_contracts.contract_manager import ContractManager @@ -16,7 +26,7 @@ class PaymentChannel: def __init__( self, token_network: TokenNetwork, - channel_identifier: typing.ChannelID, + channel_identifier: ChannelID, contract_manager: ContractManager, ): @@ -53,7 +63,7 @@ def __init__( self.participant2 = participant2 self.token_network = token_network - def token_address(self) -> typing.Address: + def token_address(self) -> Address: """ Returns the address of the token for the channel. """ return self.token_network.token_address() @@ -87,7 +97,7 @@ def settle_timeout(self) -> int: ) return event['args']['settle_timeout'] - def close_block_number(self) -> typing.Optional[int]: + def close_block_number(self) -> Optional[int]: """ Returns the channel's closed block number. """ # The closed block number is not in the smart contract storage to save @@ -132,9 +142,10 @@ def settled(self) -> bool: participant1=self.participant1, participant2=self.participant2, channel_identifier=self.channel_identifier, + block_identifier='latest', ) - def closing_address(self) -> Optional[typing.Address]: + def closing_address(self) -> Optional[Address]: """ Returns the address of the closer of the channel. """ return self.token_network.closing_address( participant1=self.participant1, @@ -150,7 +161,7 @@ def can_transfer(self) -> bool: channel_identifier=self.channel_identifier, ) - def set_total_deposit(self, total_deposit: typing.TokenAmount): + def set_total_deposit(self, total_deposit: TokenAmount): self.token_network.set_total_deposit( channel_identifier=self.channel_identifier, total_deposit=total_deposit, @@ -159,10 +170,10 @@ def set_total_deposit(self, total_deposit: typing.TokenAmount): def close( self, - nonce: typing.Nonce, - balance_hash: typing.BalanceHash, - additional_hash: typing.AdditionalHash, - signature: typing.Signature, + nonce: Nonce, + balance_hash: BalanceHash, + additional_hash: AdditionalHash, + signature: Signature, ): """ Closes the channel using the provided balance proof. """ self.token_network.close( @@ -176,11 +187,11 @@ def close( def update_transfer( self, - nonce: typing.Nonce, - balance_hash: typing.BalanceHash, - additional_hash: typing.AdditionalHash, - partner_signature: typing.Signature, - signature: typing.Signature, + nonce: Nonce, + balance_hash: BalanceHash, + additional_hash: AdditionalHash, + partner_signature: Signature, + signature: Signature, ): """ Updates the channel using the provided balance proof. """ self.token_network.update_transfer( @@ -204,10 +215,10 @@ def settle( self, transferred_amount: int, locked_amount: int, - locksroot: typing.Locksroot, + locksroot: Locksroot, partner_transferred_amount: int, partner_locked_amount: int, - partner_locksroot: typing.Locksroot, + partner_locksroot: Locksroot, ): """ Settles the channel. """ self.token_network.settle( @@ -223,8 +234,8 @@ def settle( def all_events_filter( self, - from_block: typing.BlockSpecification = None, - to_block: typing.BlockSpecification = None, + from_block: BlockSpecification = None, + to_block: BlockSpecification = None, ) -> Filter: channel_topics = [ diff --git a/raiden/network/proxies/secret_registry.py b/raiden/network/proxies/secret_registry.py index 14104fac26..6a096a831e 100644 --- a/raiden/network/proxies/secret_registry.py +++ b/raiden/network/proxies/secret_registry.py @@ -5,11 +5,12 @@ from gevent.event import AsyncResult from raiden.constants import GAS_REQUIRED_PER_SECRET_IN_BATCH, GENESIS_BLOCK_NUMBER -from raiden.exceptions import InvalidAddress, TransactionThrew +from raiden.exceptions import InvalidAddress, RaidenUnrecoverableError from raiden.network.proxies.utils import compare_contract_versions from raiden.network.rpc.client import StatelessFilter, check_address_has_code from raiden.network.rpc.transactions import check_transaction_threw -from raiden.utils import pex, privatekey_to_address, safe_gas_limit, sha3, typing +from raiden.utils import pex, privatekey_to_address, safe_gas_limit, sha3 +from raiden.utils.typing import BlockSpecification, Keccak256, Secret from raiden_contracts.constants import CONTRACT_SECRET_REGISTRY, EVENT_SECRET_REVEALED from raiden_contracts.contract_manager import ContractManager @@ -47,10 +48,10 @@ def __init__( self.node_address = privatekey_to_address(self.client.privkey) self.open_secret_transactions = dict() - def register_secret(self, secret: typing.Secret): + def register_secret(self, secret: Secret): self.register_secret_batch([secret]) - def register_secret_batch(self, secrets: List[typing.Secret]): + def register_secret_batch(self, secrets: List[Secret]): secrets_to_register = list() secrethashes_to_register = list() secrethashes_not_sent = list() @@ -82,43 +83,56 @@ def register_secret_batch(self, secrets: List[typing.Secret]): log.debug('registerSecretBatch skipped', **log_details) return - log.debug('registerSecretBatch called', **log_details) - - try: - transaction_hash = self._register_secret_batch(secrets_to_register) - except Exception as e: - log.critical('registerSecretBatch failed', **log_details) - secret_registry_transaction.set_exception(e) - raise - else: - log.info('registerSecretBatch successful', **log_details) - secret_registry_transaction.set(transaction_hash) - finally: - for secret in secrets_to_register: - self.open_secret_transactions.pop(secret, None) - - def _register_secret_batch(self, secrets): - gas_limit = self.proxy.estimate_gas('registerSecretBatch', secrets) - gas_limit = safe_gas_limit(gas_limit, len(secrets) * GAS_REQUIRED_PER_SECRET_IN_BATCH) - transaction_hash = self.proxy.transact('registerSecretBatch', gas_limit, secrets) - self.client.poll(transaction_hash) - receipt_or_none = check_transaction_threw(self.client, transaction_hash) - - if receipt_or_none: - raise TransactionThrew('registerSecretBatch', receipt_or_none) - - return transaction_hash - - def get_register_block_for_secrethash(self, secrethash: typing.Keccak256) -> int: + error_prefix = 'Call to registerSecretBatch will fail' + gas_limit = self.proxy.estimate_gas('pending', 'registerSecretBatch', secrets) + if gas_limit: + error_prefix = 'Call to registerSecretBatch failed' + try: + gas_limit = safe_gas_limit( + gas_limit, + len(secrets) * GAS_REQUIRED_PER_SECRET_IN_BATCH, + ) + transaction_hash = self.proxy.transact('registerSecretBatch', gas_limit, secrets) + self.client.poll(transaction_hash) + receipt_or_none = check_transaction_threw(self.client, transaction_hash) + except Exception as e: + secret_registry_transaction.set_exception(e) + msg = 'Unexpected exception at sending registerSecretBatch transaction' + else: + secret_registry_transaction.set(transaction_hash) + finally: + for secret in secrets_to_register: + self.open_secret_transactions.pop(secret, None) + + transaction_executed = gas_limit is not None + if not transaction_executed or receipt_or_none: + if transaction_executed: + block = receipt_or_none['blockNumber'] + else: + block = 'pending' + + self.proxy.jsonrpc_client.check_for_insufficient_eth( + transaction_name='registerSecretBatch', + transaction_executed=transaction_executed, + required_gas=len(secrets) * GAS_REQUIRED_PER_SECRET_IN_BATCH, + block_identifier=block, + ) + error_msg = f'{error_prefix}. {msg}' + log.critical(error_msg, **log_details) + raise RaidenUnrecoverableError(error_msg) + + log.info('registerSecretBatch successful', **log_details) + + def get_register_block_for_secrethash(self, secrethash: Keccak256) -> int: return self.proxy.contract.functions.getSecretRevealBlockHeight(secrethash).call() - def check_registered(self, secrethash: typing.Keccak256) -> bool: + def check_registered(self, secrethash: Keccak256) -> bool: return self.get_register_block_for_secrethash(secrethash) > 0 def secret_registered_filter( self, - from_block: typing.BlockSpecification = GENESIS_BLOCK_NUMBER, - to_block: typing.BlockSpecification = 'latest', + from_block: BlockSpecification = GENESIS_BLOCK_NUMBER, + to_block: BlockSpecification = 'latest', ) -> StatelessFilter: event_abi = self.contract_manager.get_event_abi( CONTRACT_SECRET_REGISTRY, diff --git a/raiden/network/proxies/token.py b/raiden/network/proxies/token.py index 47b2acbc9b..d0f8b050bb 100644 --- a/raiden/network/proxies/token.py +++ b/raiden/network/proxies/token.py @@ -2,16 +2,20 @@ from eth_utils import is_binary_address, to_checksum_address, to_normalized_address from raiden.constants import GAS_LIMIT_FOR_TOKEN_CONTRACT_CALL -from raiden.exceptions import TransactionThrew +from raiden.exceptions import RaidenUnrecoverableError, TransactionThrew from raiden.network.rpc.client import check_address_has_code from raiden.network.rpc.smartcontract_proxy import ContractProxy from raiden.network.rpc.transactions import check_transaction_threw from raiden.utils import pex, privatekey_to_address, safe_gas_limit +from raiden.utils.typing import Address, BlockSpecification, TokenAmount from raiden_contracts.constants import CONTRACT_HUMAN_STANDARD_TOKEN from raiden_contracts.contract_manager import ContractManager log = structlog.get_logger(__name__) # pylint: disable=invalid-name +# Determined by safe_gas_limit(estimateGas(approve)) on 17/01/19 with geth 1.8.20 +GAS_REQUIRED_FOR_APPROVE = 58792 + class Token: def __init__( @@ -36,13 +40,13 @@ def __init__( self.node_address = privatekey_to_address(jsonrpc_client.privkey) self.proxy = proxy - def allowance(self, owner, spender): + def allowance(self, owner, spender, block_identifier): return self.proxy.contract.functions.allowance( to_checksum_address(owner), to_checksum_address(spender), - ).call() + ).call(block_identifier=block_identifier) - def approve(self, allowed_address, allowance): + def approve(self, allowed_address: Address, allowance: TokenAmount): """ Aprove `allowed_address` to transfer up to `deposit` amount of token. Note: @@ -57,69 +61,98 @@ def approve(self, allowed_address, allowance): 'allowed_address': pex(allowed_address), 'allowance': allowance, } - log.debug('approve called', **log_details) - - startgas = self.proxy.estimate_gas( - 'approve', - to_checksum_address(allowed_address), - allowance, - ) - transaction_hash = self.proxy.transact( + error_prefix = 'Call to approve will fail' + gas_limit = self.proxy.estimate_gas( + 'pending', 'approve', - safe_gas_limit(startgas), to_checksum_address(allowed_address), allowance, ) - self.client.poll(transaction_hash) - receipt_or_none = check_transaction_threw(self.client, transaction_hash) - - if receipt_or_none: - user_balance = self.balance_of(self.client.address) - - # If the balance is zero, either the smart contract doesnt have a - # balanceOf function or the actual balance is zero - if user_balance == 0: - msg = ( - "Approve failed. \n" - "Your account balance is 0 (zero), either the smart " - "contract is not a valid ERC20 token or you don't have funds " - "to use for openning a channel. " - ) - - # The approve call failed, check the user has enough balance - # (assuming the token smart contract may check for the maximum - # allowance, which is not necessarily the case) - elif user_balance < allowance: - msg = ( - 'Approve failed. \n' - 'Your account balance is {}, nevertheless the call to ' - 'approve failed. Please make sure the corresponding smart ' - 'contract is a valid ERC20 token.' - ).format(user_balance) - - # If the user has enough balance, warn the user the smart contract - # may not have the approve function. + if gas_limit: + error_prefix = 'Call to approve failed' + log.debug('approve called', **log_details) + transaction_hash = self.proxy.transact( + 'approve', + safe_gas_limit(gas_limit), + to_checksum_address(allowed_address), + allowance, + ) + + self.client.poll(transaction_hash) + receipt_or_none = check_transaction_threw(self.client, transaction_hash) + + transaction_executed = gas_limit is not None + if not transaction_executed or receipt_or_none: + if transaction_executed: + block = receipt_or_none['blockNumber'] else: - msg = ( - f'Approve failed. \n' - f'Your account balance is {user_balance}, ' - f'the request allowance is {allowance}. ' - f'The smart contract may be rejecting your request for the ' - f'lack of balance.' - ) + block = 'pending' - log.critical(f'approve failed, {msg}', **log_details) - raise TransactionThrew(msg, receipt_or_none) + self.proxy.jsonrpc_client.check_for_insufficient_eth( + transaction_name='approve', + transaction_executed=transaction_executed, + required_gas=GAS_REQUIRED_FOR_APPROVE, + block_identifier=block, + ) + + msg = self._check_why_approved_failed(allowance, block) + error_msg = f'{error_prefix}. {msg}' + log.critical(error_msg, **log_details) + raise RaidenUnrecoverableError(error_msg) log.info('approve successful', **log_details) - def balance_of(self, address): + def _check_why_approved_failed( + self, + allowance: TokenAmount, + block_identifier: BlockSpecification, + ) -> str: + user_balance = self.balance_of( + address=self.client.address, + block_identifier=block_identifier, + ) + + # If the balance is zero, either the smart contract doesnt have a + # balanceOf function or the actual balance is zero + if user_balance == 0: + msg = ( + "Approve failed. \n" + "Your account balance is 0 (zero), either the smart " + "contract is not a valid ERC20 token or you don't have funds " + "to use for openning a channel. " + ) + + # The approve call failed, check the user has enough balance + # (assuming the token smart contract may check for the maximum + # allowance, which is not necessarily the case) + elif user_balance < allowance: + msg = ( + f'Approve failed. \n' + f'Your account balance is {user_balance}. ' + f'The requested allowance is {allowance}. ' + f'The smart contract may be rejecting your request due to the ' + f'lack of balance.' + ) + + # If the user has enough balance, warn the user the smart contract + # may not have the approve function. + else: + msg = ( + f'Approve failed. \n' + f'Your account balance is {user_balance}. Nevertheless the call to' + f'approve failed. Please make sure the corresponding smart ' + f'contract is a valid ERC20 token.' + ).format(user_balance) + + return msg + + def balance_of(self, address, block_identifier='latest'): """ Return the balance of `address`. """ return self.proxy.contract.functions.balanceOf( to_checksum_address(address), - ).call() + ).call(block_identifier=block_identifier) def transfer(self, to_address, amount): log_details = { diff --git a/raiden/network/proxies/token_network.py b/raiden/network/proxies/token_network.py index 911e540753..63c9292fc7 100644 --- a/raiden/network/proxies/token_network.py +++ b/raiden/network/proxies/token_network.py @@ -1,5 +1,5 @@ from collections import defaultdict -from typing import List, NamedTuple, Optional +from typing import Dict, List, NamedTuple, Optional, Tuple, Union import structlog from eth_utils import ( @@ -22,15 +22,32 @@ RaidenRecoverableError, RaidenUnrecoverableError, SamePeerAddress, - TransactionThrew, ) from raiden.network.proxies import Token from raiden.network.proxies.utils import compare_contract_versions from raiden.network.rpc.client import StatelessFilter, check_address_has_code from raiden.network.rpc.transactions import check_transaction_threw from raiden.transfer.balance_proof import pack_balance_proof -from raiden.utils import pex, privatekey_to_address, safe_gas_limit, typing +from raiden.utils import pex, privatekey_to_address, safe_gas_limit from raiden.utils.signing import eth_recover +from raiden.utils.typing import ( + AdditionalHash, + Address, + BalanceHash, + BlockNumber, + BlockSpecification, + ChainID, + ChannelID, + Locksroot, + MerkleTreeLeaves, + Nonce, + Signature, + T_ChannelID, + T_ChannelState, + TokenAmount, + TokenNetworkAddress, + TokenNetworkID, +) from raiden_contracts.constants import ( CONTRACT_TOKEN_NETWORK, GAS_REQUIRED_FOR_CLOSE_CHANNEL, @@ -48,20 +65,20 @@ class ChannelData(NamedTuple): - channel_identifier: typing.ChannelID - settle_block_number: typing.BlockNumber - state: typing.ChannelState + channel_identifier: ChannelID + settle_block_number: BlockNumber + state: ChannelState class ParticipantDetails(NamedTuple): - address: typing.Address - deposit: typing.TokenAmount - withdrawn: typing.TokenAmount + address: Address + deposit: TokenAmount + withdrawn: TokenAmount is_closer: bool - balance_hash: typing.BalanceHash - nonce: typing.Nonce - locksroot: typing.Locksroot - locked_amount: typing.TokenAmount + balance_hash: BalanceHash + nonce: Nonce + locksroot: Locksroot + locked_amount: TokenAmount class ParticipantsDetails(NamedTuple): @@ -70,7 +87,7 @@ class ParticipantsDetails(NamedTuple): class ChannelDetails(NamedTuple): - chain_id: typing.ChainID + chain_id: ChainID channel_data: int participants_data: ParticipantsDetails @@ -79,7 +96,7 @@ class TokenNetwork: def __init__( self, jsonrpc_client, - token_network_address: typing.TokenNetworkAddress, + token_network_address: TokenNetworkAddress, contract_manager: ContractManager, ): if not is_binary_address(token_network_address): @@ -87,7 +104,7 @@ def __init__( check_address_has_code( jsonrpc_client, - typing.Address(token_network_address), + Address(token_network_address), CONTRACT_TOKEN_NETWORK, ) @@ -101,7 +118,7 @@ def __init__( proxy=proxy, expected_version=contract_manager.contracts_version, contract_name=CONTRACT_TOKEN_NETWORK, - address=typing.Address(token_network_address), + address=Address(token_network_address), ) self.address = token_network_address @@ -118,33 +135,30 @@ def __init__( # setTotalDeposit calls. self.deposit_lock = Semaphore() - def _call_and_check_result(self, function_name: str, *args): + def _call_and_check_result( + self, + block_identifier: BlockSpecification, + function_name: str, + *args, + ): fn = getattr(self.proxy.contract.functions, function_name) - call_result = fn(*args).call() + call_result = fn(*args).call(block_identifier=block_identifier) if call_result == b'': raise RuntimeError(f"Call to '{function_name}' returned nothing") return call_result - def token_address(self) -> typing.Address: + def token_address(self) -> Address: """ Return the token of this manager. """ return to_canonical_address(self.proxy.contract.functions.token().call()) - def new_netting_channel( + def _new_channel_preconditions( self, - partner: typing.Address, + partner: Address, settle_timeout: int, - ) -> typing.ChannelID: - """ Creates a new channel in the TokenNetwork contract. - - Args: - partner: The peer to open the channel with. - settle_timeout: The settle timeout to use for this channel. - - Returns: - The ChannelID of the new netting channel. - """ + block_identifier: BlockSpecification, + ): if not is_binary_address(partner): raise InvalidAddress('Expected binary address format for channel partner') @@ -162,20 +176,94 @@ def new_netting_channel( if self.node_address == partner: raise SamePeerAddress('The other peer must not have the same address as the client.') + channel_exists = self.channel_exists_and_not_settled( + participant1=self.node_address, + participant2=partner, + block_identifier=block_identifier, + ) + if channel_exists: + raise DuplicatedChannelError('Channel with given partner address already exists') + + def _new_channel_postconditions( + self, + partner: Address, + block: BlockSpecification, + ) -> Tuple[bool, str]: + channel_created = self.channel_exists_and_not_settled( + participant1=self.node_address, + participant2=partner, + block_identifier=block, + ) + if channel_created: + return True, 'Channel with given partner address already exists' + return False, '' + + def new_netting_channel( + self, + partner: Address, + settle_timeout: int, + ) -> ChannelID: + """ Creates a new channel in the TokenNetwork contract. + + Args: + partner: The peer to open the channel with. + settle_timeout: The settle timeout to use for this channel. + + Returns: + The ChannelID of the new netting channel. + """ + + self._new_channel_preconditions(partner, settle_timeout, 'pending') log_details = { 'peer1': pex(self.node_address), 'peer2': pex(partner), } - log.debug('new_netting_channel called', **log_details) + gas_limit = self.proxy.estimate_gas( + 'pending', + 'openChannel', + self.node_address, + partner, + settle_timeout, + ) + if not gas_limit: + channel_exists = self.channel_exists_and_not_settled( + participant1=self.node_address, + participant2=partner, + block_identifier='pending', + ) + if channel_exists: + raise DuplicatedChannelError('Duplicated channel') + + log.critical('Call to openChannel will fail', **log_details) + raise RaidenUnrecoverableError('Call to openChannel will fail') + log.debug('new_netting_channel called', **log_details) # Prevent concurrent attempts to open a channel with the same token and # partner address. - if partner not in self.open_channel_transactions: + if gas_limit and partner not in self.open_channel_transactions: new_open_channel_transaction = AsyncResult() self.open_channel_transactions[partner] = new_open_channel_transaction - + gas_limit = safe_gas_limit(gas_limit, GAS_REQUIRED_FOR_OPEN_CHANNEL) try: - transaction_hash = self._new_netting_channel(partner, settle_timeout) + transaction_hash = self.proxy.transact( + 'openChannel', + gas_limit, + self.node_address, + partner, + settle_timeout, + ) + self.client.poll(transaction_hash) + receipt_or_none = check_transaction_threw(self.client, transaction_hash) + if receipt_or_none: + known_race, msg = self._new_channel_postconditions( + partner=partner, + block=receipt_or_none['blockNumber'], + ) + if known_race: + raise DuplicatedChannelError(msg) + log.critical('new_netting_channel failed', **log_details) + raise RaidenUnrecoverableError('creating new channel failed') + except Exception as e: log.critical('new_netting_channel failed', **log_details) new_open_channel_transaction.set_exception(e) @@ -188,64 +276,48 @@ def new_netting_channel( # All other concurrent threads should block on the result of opening this channel self.open_channel_transactions[partner].get() - channel_created = self.channel_exists_and_not_settled(self.node_address, partner) - if channel_created is False: - log.critical('new_netting_channel failed', **log_details) - raise RaidenUnrecoverableError('creating new channel failed') + if not gas_limit: + self.proxy.jsonrpc_client.check_for_insufficient_eth( + transaction_name='openChannel', + transaction_executed=False, + required_gas=GAS_REQUIRED_FOR_OPEN_CHANNEL, + block_identifier='pending', + ) + known_race, msg = self._new_channel_postconditions( + partner=partner, + block='pending', + ) + if known_race: + raise DuplicatedChannelError(msg) + log.critical('new_netting_channel call will fail', **log_details) + raise RaidenUnrecoverableError('Creating a new channel will fail') - channel_identifier: typing.ChannelID = self.detail_channel( - self.node_address, - partner, + channel_identifier: ChannelID = self.detail_channel( + participant1=self.node_address, + participant2=partner, + block_identifier='latest', ).channel_identifier log_details['channel_identifier'] = str(channel_identifier) log.info('new_netting_channel successful', **log_details) return channel_identifier - def _new_netting_channel(self, partner: typing.Address, settle_timeout: int): - if self.channel_exists_and_not_settled(self.node_address, partner): - raise DuplicatedChannelError('Channel with given partner address already exists') - - gas_limit = self.proxy.estimate_gas( - 'openChannel', - self.node_address, - partner, - settle_timeout, - ) - gas_limit = safe_gas_limit(gas_limit, GAS_REQUIRED_FOR_OPEN_CHANNEL) - - transaction_hash = self.proxy.transact( - 'openChannel', - gas_limit, - self.node_address, - partner, - settle_timeout, - ) - - if not transaction_hash: - raise RuntimeError('open channel transaction failed') - - self.client.poll(transaction_hash) - - if check_transaction_threw(self.client, transaction_hash): - raise DuplicatedChannelError('Duplicated channel') - - return transaction_hash - def _inspect_channel_identifier( self, - participant1: typing.Address, - participant2: typing.Address, + participant1: Address, + participant2: Address, called_by_fn: str, - channel_identifier: typing.ChannelID = None, - ) -> typing.ChannelID: + channel_identifier: ChannelID = None, + block_identifier: BlockSpecification = 'pending', + ) -> ChannelID: if not channel_identifier: channel_identifier = self._call_and_check_result( + block_identifier, 'getChannelIdentifier', to_checksum_address(participant1), to_checksum_address(participant2), ) - assert isinstance(channel_identifier, typing.T_ChannelID) + assert isinstance(channel_identifier, T_ChannelID) if channel_identifier == 0: raise RaidenRecoverableError( f'When calling {called_by_fn} either 0 value was given for the ' @@ -257,13 +329,19 @@ def _inspect_channel_identifier( def channel_exists_and_not_settled( self, - participant1: typing.Address, - participant2: typing.Address, - channel_identifier: typing.ChannelID = None, + participant1: Address, + participant2: Address, + channel_identifier: ChannelID = None, + block_identifier: BlockSpecification = 'pending', ) -> bool: """Returns if the channel exists and is in a non-settled state""" try: - channel_state = self._get_channel_state(participant1, participant2, channel_identifier) + channel_state = self._get_channel_state( + participant1=participant1, + participant2=participant2, + channel_identifier=channel_identifier, + block_identifier=block_identifier, + ) except RaidenRecoverableError: return False exists_and_not_settled = ( @@ -274,13 +352,15 @@ def channel_exists_and_not_settled( def detail_participant( self, - channel_identifier: typing.ChannelID, - participant: typing.Address, - partner: typing.Address, + channel_identifier: ChannelID, + participant: Address, + partner: Address, + block_identifier: BlockSpecification, ) -> ParticipantDetails: """ Returns a dictionary with the channel participant information. """ data = self._call_and_check_result( + block_identifier, 'getChannelParticipantInfo', channel_identifier, to_checksum_address(participant), @@ -299,9 +379,10 @@ def detail_participant( def detail_channel( self, - participant1: typing.Address, - participant2: typing.Address, - channel_identifier: typing.ChannelID = None, + participant1: Address, + participant2: Address, + channel_identifier: ChannelID = None, + block_identifier: BlockSpecification = 'pending', ) -> ChannelData: """ Returns a ChannelData instance with the channel specific information. @@ -314,9 +395,11 @@ def detail_channel( participant2=participant2, called_by_fn='detail_channel', channel_identifier=channel_identifier, + block_identifier=block_identifier, ) channel_data = self._call_and_check_result( + block_identifier, 'getChannelInfo', channel_identifier, to_checksum_address(participant1), @@ -331,9 +414,10 @@ def detail_channel( def detail_participants( self, - participant1: typing.Address, - participant2: typing.Address, - channel_identifier: typing.ChannelID = None, + participant1: Address, + participant2: Address, + channel_identifier: ChannelID = None, + block_identifier: BlockSpecification = 'pending', ) -> ParticipantsDetails: """ Returns a ParticipantsDetails instance with the participants' channel information. @@ -352,17 +436,29 @@ def detail_participants( participant2=participant2, called_by_fn='details_participants', channel_identifier=channel_identifier, + block_identifier=block_identifier, ) - our_data = self.detail_participant(channel_identifier, participant1, participant2) - partner_data = self.detail_participant(channel_identifier, participant2, participant1) + our_data = self.detail_participant( + channel_identifier=channel_identifier, + participant=participant1, + partner=participant2, + block_identifier=block_identifier, + ) + partner_data = self.detail_participant( + channel_identifier=channel_identifier, + participant=participant2, + partner=participant1, + block_identifier=block_identifier, + ) return ParticipantsDetails(our_details=our_data, partner_details=partner_data) def detail( self, - participant1: typing.Address, - participant2: typing.Address, - channel_identifier: typing.ChannelID = None, + participant1: Address, + participant2: Address, + channel_identifier: ChannelID = None, + block_identifier: BlockSpecification = 'pending', ) -> ChannelDetails: """ Returns a ChannelDetails instance with all the details of the channel and the channel participants. @@ -376,11 +472,17 @@ def detail( if self.node_address == participant2: participant1, participant2 = participant2, participant1 - channel_data = self.detail_channel(participant1, participant2, channel_identifier) + channel_data = self.detail_channel( + participant1=participant1, + participant2=participant2, + channel_identifier=channel_identifier, + block_identifier=block_identifier, + ) participants_data = self.detail_participants( - participant1, - participant2, - channel_data.channel_identifier, + participant1=participant1, + participant2=participant2, + channel_identifier=channel_data.channel_identifier, + block_identifier=block_identifier, ) chain_id = self.proxy.contract.functions.chain_id().call() @@ -400,9 +502,9 @@ def settlement_timeout_max(self) -> int: def channel_is_opened( self, - participant1: typing.Address, - participant2: typing.Address, - channel_identifier: typing.ChannelID, + participant1: Address, + participant2: Address, + channel_identifier: ChannelID, ) -> bool: """ Returns true if the channel is in an open state, false otherwise. """ try: @@ -413,9 +515,9 @@ def channel_is_opened( def channel_is_closed( self, - participant1: typing.Address, - participant2: typing.Address, - channel_identifier: typing.ChannelID, + participant1: Address, + participant2: Address, + channel_identifier: ChannelID, ) -> bool: """ Returns true if the channel is in a closed state, false otherwise. """ try: @@ -426,28 +528,40 @@ def channel_is_closed( def channel_is_settled( self, - participant1: typing.Address, - participant2: typing.Address, - channel_identifier: typing.ChannelID, + participant1: Address, + participant2: Address, + channel_identifier: ChannelID, + block_identifier: BlockSpecification, ) -> bool: """ Returns true if the channel is in a settled state, false otherwise. """ try: - channel_state = self._get_channel_state(participant1, participant2, channel_identifier) + channel_state = self._get_channel_state( + participant1=participant1, + participant2=participant2, + channel_identifier=channel_identifier, + block_identifier=block_identifier, + ) except RaidenRecoverableError: return False return channel_state >= ChannelState.SETTLED def closing_address( self, - participant1: typing.Address, - participant2: typing.Address, - channel_identifier: typing.ChannelID = None, - ) -> Optional[typing.Address]: + participant1: Address, + participant2: Address, + channel_identifier: ChannelID = None, + block_identifier: BlockSpecification = 'latest', + ) -> Optional[Address]: """ Returns the address of the closer, if the channel is closed and not settled. None otherwise. """ try: - channel_data = self.detail_channel(participant1, participant2, channel_identifier) + channel_data = self.detail_channel( + participant1=participant1, + participant2=participant2, + channel_identifier=channel_identifier, + block_identifier=block_identifier, + ) except RaidenRecoverableError: return None @@ -458,6 +572,7 @@ def closing_address( participant1=participant1, participant2=participant2, channel_identifier=channel_data.channel_identifier, + block_identifier=block_identifier, ) if participants_data.our_details.is_closer: @@ -469,9 +584,9 @@ def closing_address( def can_transfer( self, - participant1: typing.Address, - participant2: typing.Address, - channel_identifier: typing.ChannelID, + participant1: Address, + participant2: Address, + channel_identifier: ChannelID, ) -> bool: """ Returns True if the channel is opened and the node has deposit in it. @@ -484,192 +599,300 @@ def can_transfer( return False deposit = self.detail_participant( - channel_identifier, - participant1, - participant2, + channel_identifier=channel_identifier, + participant=participant1, + partner=participant2, + block_identifier='latest', ).deposit return deposit > 0 + def _deposit_preconditions( + self, + channel_identifier: ChannelID, + total_deposit: TokenAmount, + partner: Address, + token: Token, + block_identifier: BlockSpecification, + ) -> Tuple[TokenAmount, Dict]: + if not isinstance(total_deposit, int): + raise ValueError('total_deposit needs to be an integral number.') + + self._check_for_outdated_channel( + participant1=self.node_address, + participant2=partner, + channel_identifier=channel_identifier, + block_identifier=block_identifier, + ) + + # setTotalDeposit requires a monotonically increasing value. This + # is used to handle concurrent actions: + # + # - The deposits will be done in order, i.e. the monotonic + # property is preserved by the caller + # - The race of two deposits will be resolved with the larger + # deposit winning + # - Retries wont have effect + # + # This check is serialized with the channel_operations_lock to avoid + # sending invalid transactions on-chain (decreasing total deposit). + # + previous_total_deposit = self.detail_participant( + channel_identifier=channel_identifier, + participant=self.node_address, + partner=partner, + block_identifier=block_identifier, + ).deposit + amount_to_deposit = total_deposit - previous_total_deposit + + log_details = { + 'token_network': pex(self.address), + 'channel_identifier': channel_identifier, + 'node': pex(self.node_address), + 'partner': pex(partner), + 'new_total_deposit': total_deposit, + 'previous_total_deposit': previous_total_deposit, + } + + # These two scenarios can happen if two calls to deposit happen + # and then we get here on the second call + if total_deposit < previous_total_deposit: + msg = ( + f'Current total deposit ({previous_total_deposit}) is already larger ' + f'than the requested total deposit amount ({total_deposit})' + ) + log.info('setTotalDeposit failed', reason=msg, **log_details) + raise DepositMismatch(msg) + + if amount_to_deposit <= 0: + msg = ( + f'new_total_deposit - previous_total_deposit must be greater than 0. ' + f'new_total_deposit={total_deposit} ' + f'previous_total_deposit={previous_total_deposit}' + ) + log.info('setTotalDeposit failed', reason=msg, **log_details) + raise DepositMismatch(msg) + + # A node may be setting up multiple channels for the same token + # concurrently. Because each deposit changes the user balance this + # check must be serialized with the operation locks. + # + # This check is merely informational, used to avoid sending + # transactions which are known to fail. + # + # It is serialized with the deposit_lock to avoid sending invalid + # transactions on-chain (account without balance). The lock + # channel_operations_lock is not sufficient, as it allows two + # concurrent deposits for different channels. + # + current_balance = token.balance_of(self.node_address) + if current_balance < amount_to_deposit: + msg = ( + f'new_total_deposit - previous_total_deposit = {amount_to_deposit} can not ' + f'be larger than the available balance {current_balance}, ' + f'for token at address {pex(token.address)}' + ) + log.info('setTotalDeposit failed', reason=msg, **log_details) + raise DepositMismatch(msg) + + # If there are channels being set up concurrenlty either the + # allowance must be accumulated *or* the calls to `approve` and + # `setTotalDeposit` must be serialized. This is necessary otherwise + # the deposit will fail. + # + # Calls to approve and setTotalDeposit are serialized with the + # deposit_lock to avoid transaction failure, because with two + # concurrent deposits, we may have the transactions executed in the + # following order + # + # - approve + # - approve + # - setTotalDeposit + # - setTotalDeposit + # + # in which case the second `approve` will overwrite the first, + # and the first `setTotalDeposit` will consume the allowance, + # making the second deposit fail. + token.approve(Address(self.address), amount_to_deposit) + + return amount_to_deposit, log_details + def set_total_deposit( self, - channel_identifier: typing.ChannelID, - total_deposit: typing.TokenAmount, - partner: typing.Address, + channel_identifier: ChannelID, + total_deposit: TokenAmount, + partner: Address, ): """ Set total token deposit in the channel to total_deposit. Raises: + ChannelBusyError: If the channel is busy with another operation RuntimeError: If the token address is empty. """ - if not isinstance(total_deposit, int): - raise ValueError('total_deposit needs to be an integral number.') - - self._check_for_outdated_channel( - self.node_address, - partner, - channel_identifier, - ) - token_address = self.token_address() token = Token( jsonrpc_client=self.client, token_address=token_address, contract_manager=self.contract_manager, ) - + error_prefix = 'setTotalDeposit call will fail' with self.channel_operations_lock[partner], self.deposit_lock: - # setTotalDeposit requires a monotonically increasing value. This - # is used to handle concurrent actions: - # - # - The deposits will be done in order, i.e. the monotonic - # property is preserved by the caller - # - The race of two deposits will be resolved with the larger - # deposit winning - # - Retries wont have effect - # - # This check is serialized with the channel_operations_lock to avoid - # sending invalid transactions on-chain (decreasing total deposit). - # - previous_total_deposit = self.detail_participant( - channel_identifier, - self.node_address, - partner, - ).deposit - amount_to_deposit = total_deposit - previous_total_deposit - - log_details = { - 'token_network': pex(self.address), - 'channel_identifier': channel_identifier, - 'node': pex(self.node_address), - 'partner': pex(partner), - 'new_total_deposit': total_deposit, - 'previous_total_deposit': previous_total_deposit, - } - log.debug('setTotalDeposit called', **log_details) - - # These two scenarios can happen if two calls to deposit happen - # and then we get here on the second call - if total_deposit < previous_total_deposit: - msg = ( - f'Current total deposit ({previous_total_deposit}) is already larger ' - f'than the requested total deposit amount ({total_deposit})' - ) - log.info('setTotalDeposit failed', reason=msg, **log_details) - raise DepositMismatch(msg) - - if amount_to_deposit <= 0: - msg = ( - f'new_total_deposit - previous_total_deposit must be greater than 0. ' - f'new_total_deposit={total_deposit} ' - f'previous_total_deposit={previous_total_deposit}' - ) - log.info('setTotalDeposit failed', reason=msg, **log_details) - raise DepositMismatch(msg) - - # A node may be setting up multiple channels for the same token - # concurrently. Because each deposit changes the user balance this - # check must be serialized with the operation locks. - # - # This check is merely informational, used to avoid sending - # transactions which are known to fail. - # - # It is serialized with the deposit_lock to avoid sending invalid - # transactions on-chain (account without balance). The lock - # channel_operations_lock is not sufficient, as it allows two - # concurrent deposits for different channels. - # - current_balance = token.balance_of(self.node_address) - if current_balance < amount_to_deposit: - msg = ( - f'new_total_deposit - previous_total_deposit = {amount_to_deposit} can not ' - f'be larger than the available balance {current_balance}, ' - f'for token at address {pex(token_address)}' - ) - log.info('setTotalDeposit failed', reason=msg, **log_details) - raise DepositMismatch(msg) - - # If there are channels being set up concurrenlty either the - # allowance must be accumulated *or* the calls to `approve` and - # `setTotalDeposit` must be serialized. This is necessary otherwise - # the deposit will fail. - # - # Calls to approve and setTotalDeposit are serialized with the - # deposit_lock to avoid transaction failure, because with two - # concurrent deposits, we may have the transactions executed in the - # following order - # - # - approve - # - approve - # - setTotalDeposit - # - setTotalDeposit - # - # in which case the second `approve` will overwrite the first, - # and the first `setTotalDeposit` will consume the allowance, - # making the second deposit fail. - token.approve(self.address, amount_to_deposit) - - gas_limit = self.proxy.estimate_gas( - 'setTotalDeposit', - channel_identifier, - self.node_address, - total_deposit, - partner, + amount_to_deposit, log_details = self._deposit_preconditions( + channel_identifier=channel_identifier, + total_deposit=total_deposit, + partner=partner, + token=token, + block_identifier='pending', ) - gas_limit = safe_gas_limit(gas_limit, GAS_REQUIRED_FOR_SET_TOTAL_DEPOSIT) - transaction_hash = self.proxy.transact( + gas_limit = self.proxy.estimate_gas( + 'pending', 'setTotalDeposit', - gas_limit, channel_identifier, self.node_address, total_deposit, partner, ) - self.client.poll(transaction_hash) - receipt_or_none = check_transaction_threw(self.client, transaction_hash) + if gas_limit: + gas_limit = safe_gas_limit(gas_limit, GAS_REQUIRED_FOR_SET_TOTAL_DEPOSIT) + error_prefix = 'setTotalDeposit call failed' - if receipt_or_none: - latest_deposit = self.detail_participant( + log.debug('setTotalDeposit called', **log_details) + transaction_hash = self.proxy.transact( + 'setTotalDeposit', + gas_limit, channel_identifier, self.node_address, + total_deposit, partner, - ).deposit + ) + self.client.poll(transaction_hash) + receipt_or_none = check_transaction_threw(self.client, transaction_hash) - if token.allowance(self.node_address, self.address) < amount_to_deposit: - log_msg = ( - 'The allowance is insufficient, ' - 'check concurrent deposits for the same token network ' - 'but different proxies.' - ) - elif token.balance_of(self.node_address) < amount_to_deposit: - log_msg = "The address doesn't have enough funds" - elif latest_deposit < total_deposit: - log_msg = 'The tokens were not transferred' + transaction_executed = gas_limit is not None + if not transaction_executed or receipt_or_none: + if transaction_executed: + block = receipt_or_none['blockNumber'] else: - log_msg = 'unknown' + block = 'pending' - log.critical('setTotalDeposit failed', reason=log_msg, **log_details) - - self._check_channel_state_for_deposit( - self.node_address, - partner, - channel_identifier, - total_deposit, + self.proxy.jsonrpc_client.check_for_insufficient_eth( + transaction_name='setTotalDeposit', + transaction_executed=transaction_executed, + required_gas=GAS_REQUIRED_FOR_SET_TOTAL_DEPOSIT, + block_identifier=block, + ) + error_type, msg = self._check_why_deposit_failed( + channel_identifier=channel_identifier, + partner=partner, + token=token, + amount_to_deposit=amount_to_deposit, + total_deposit=total_deposit, + transaction_executed=transaction_executed, + block_identifier=block, ) - raise TransactionThrew('Deposit', receipt_or_none) + error_msg = f'{error_prefix}. {msg}' + if error_type == RaidenRecoverableError: + log.warning(error_msg, **log_details) + else: + log.critical(error_msg, **log_details) + raise error_type(error_msg) log.info('setTotalDeposit successful', **log_details) + def _check_why_deposit_failed( + self, + channel_identifier: ChannelID, + partner: Address, + token: Token, + amount_to_deposit: TokenAmount, + total_deposit: TokenAmount, + transaction_executed: bool, + block_identifier: BlockSpecification, + ) -> Tuple[ + Union[RaidenRecoverableError, RaidenUnrecoverableError], + str, + ]: + error_type = RaidenUnrecoverableError + msg = '' + latest_deposit = self.detail_participant( + channel_identifier=channel_identifier, + participant=self.node_address, + partner=partner, + block_identifier=block_identifier, + ).deposit + + if token.allowance(self.node_address, self.address, block_identifier) < amount_to_deposit: + msg = ( + 'The allowance is insufficient. Check concurrent deposits ' + 'for the same token network but different proxies.' + ) + elif token.balance_of(self.node_address, block_identifier) < amount_to_deposit: + msg = 'The address doesnt have enough tokens' + elif transaction_executed and latest_deposit < total_deposit: + msg = 'The tokens were not transferred' + else: + participant_details = self.detail_participants( + participant1=self.node_address, + participant2=partner, + channel_identifier=channel_identifier, + block_identifier=block_identifier, + ) + channel_state = self._get_channel_state( + participant1=self.node_address, + participant2=partner, + channel_identifier=channel_identifier, + block_identifier=block_identifier, + ) + # Check if deposit is being made on a nonexistent channel + if channel_state in (ChannelState.NONEXISTENT, ChannelState.REMOVED): + msg = ( + f'Channel between participant {self.node_address} ' + f'and {partner} does not exist', + ) + # Deposit was prohibited because the channel is settled + elif channel_state == ChannelState.SETTLED: + msg = 'Deposit is not possible due to channel being settled' + # Deposit was prohibited because the channel is closed + elif channel_state == ChannelState.CLOSED: + error_type = RaidenRecoverableError + msg = 'Channel is already closed' + elif participant_details.our_details.deposit < total_deposit: + msg = 'Deposit amount did not increase after deposit transaction' + + return error_type, msg + + def _close_preconditions( + self, + channel_identifier: ChannelID, + partner: Address, + block_identifier: BlockSpecification, + ): + self._check_for_outdated_channel( + participant1=self.node_address, + participant2=partner, + channel_identifier=channel_identifier, + block_identifier=block_identifier, + ) + + error_type, msg = self._check_channel_state_for_close( + participant1=self.node_address, + participant2=partner, + channel_identifier=channel_identifier, + block_identifier=block_identifier, + ) + if error_type: + raise error_type(msg) + def close( self, - channel_identifier: typing.ChannelID, - partner: typing.Address, - balance_hash: typing.BalanceHash, - nonce: typing.Nonce, - additional_hash: typing.AdditionalHash, - signature: typing.Signature, + channel_identifier: ChannelID, + partner: Address, + balance_hash: BalanceHash, + nonce: Nonce, + additional_hash: AdditionalHash, + signature: Signature, ): """ Close the channel using the provided balance proof. @@ -689,22 +912,17 @@ def close( } log.debug('closeChannel called', **log_details) - self._check_for_outdated_channel( - self.node_address, - partner, - channel_identifier, - ) - - self._check_channel_state_for_close( - self.node_address, - partner, + self._close_preconditions( channel_identifier, + partner=partner, + block_identifier='pending', ) + error_prefix = 'closeChannel call will fail' with self.channel_operations_lock[partner]: - transaction_hash = self.proxy.transact( + gas_limit = self.proxy.estimate_gas( + 'pending', 'closeChannel', - safe_gas_limit(GAS_REQUIRED_FOR_CLOSE_CHANNEL), channel_identifier, partner, balance_hash, @@ -712,50 +930,70 @@ def close( additional_hash, signature, ) - self.client.poll(transaction_hash) - receipt_or_none = check_transaction_threw(self.client, transaction_hash) - if receipt_or_none: - log.critical('closeChannel failed', **log_details) - - self._check_channel_state_for_close( - self.node_address, - partner, + if gas_limit: + error_prefix = 'closeChannel call failed' + transaction_hash = self.proxy.transact( + 'closeChannel', + safe_gas_limit(gas_limit, GAS_REQUIRED_FOR_CLOSE_CHANNEL), channel_identifier, + partner, + balance_hash, + nonce, + additional_hash, + signature, ) + self.client.poll(transaction_hash) + receipt_or_none = check_transaction_threw(self.client, transaction_hash) - raise TransactionThrew('Close', receipt_or_none) + transaction_executed = gas_limit is not None + if not transaction_executed or receipt_or_none: + if transaction_executed: + block = receipt_or_none['blockNumber'] + else: + block = 'pending' - log.info('closeChannel successful', **log_details) + self.proxy.jsonrpc_client.check_for_insufficient_eth( + transaction_name='closeChannel', + transaction_executed=transaction_executed, + required_gas=GAS_REQUIRED_FOR_CLOSE_CHANNEL, + block_identifier=block, + ) + error_type, msg = self._check_channel_state_for_close( + participant1=self.node_address, + participant2=partner, + channel_identifier=channel_identifier, + block_identifier=block, + ) + error_msg = f'{error_prefix}. {msg}' + if error_type == RaidenRecoverableError: + log.warning(error_msg, **log_details) + else: + # error_type can also be None above in which case it's + # unknown reason why we would fail. + error_type = RaidenUnrecoverableError + log.critical(error_msg, **log_details) - def update_transfer( - self, - channel_identifier: typing.ChannelID, - partner: typing.Address, - balance_hash: typing.BalanceHash, - nonce: typing.Nonce, - additional_hash: typing.AdditionalHash, - closing_signature: typing.Signature, - non_closing_signature: typing.Signature, - ): - log_details = { - 'token_network': pex(self.address), - 'node': pex(self.node_address), - 'partner': pex(partner), - 'nonce': nonce, - 'balance_hash': encode_hex(balance_hash), - 'additional_hash': encode_hex(additional_hash), - 'closing_signature': encode_hex(closing_signature), - 'non_closing_signature': encode_hex(non_closing_signature), - } - log.debug('updateNonClosingBalanceProof called', **log_details) + raise error_type(error_msg) + + log.info('closeChannel successful', **log_details) + def _update_preconditions( + self, + channel_identifier: ChannelID, + partner: Address, + balance_hash: BalanceHash, + nonce: Nonce, + additional_hash: AdditionalHash, + closing_signature: Signature, + block_identifier: BlockSpecification, + ) -> None: data_that_was_signed = pack_balance_proof( nonce=nonce, balance_hash=balance_hash, additional_hash=additional_hash, channel_identifier=channel_identifier, - token_network_identifier=typing.TokenNetworkID(self.address), + token_network_identifier=TokenNetworkID(self.address), chain_id=self.proxy.contract.functions.chain_id().call(), ) @@ -774,48 +1012,78 @@ def update_transfer( # Exception is raised if the public key recovery failed. except Exception: # pylint: disable=broad-except msg = "Couldn't verify the balance proof signature" - log.critical(f'updateNonClosingBalanceProof failed, {msg}', **log_details) - raise RaidenUnrecoverableError(msg) + return RaidenUnrecoverableError, msg if signer_address != partner: - msg = 'Invalid balance proof signature' - log.critical(f'updateNonClosingBalanceProof failed, {msg}', **log_details) - raise RaidenUnrecoverableError(msg) + raise RaidenUnrecoverableError('Invalid balance proof signature') self._check_for_outdated_channel( self.node_address, partner, channel_identifier, + block_identifier=block_identifier, ) detail = self.detail_channel( participant1=self.node_address, participant2=partner, channel_identifier=channel_identifier, + block_identifier=block_identifier, ) if detail.state != ChannelState.CLOSED: - msg = 'Channel is not in a closed state' - log.critical(f'updateNonClosingBalanceProof failed, {msg}', **log_details) - raise RaidenUnrecoverableError(msg) - - if detail.settle_block_number < self.client.block_number(): + raise RaidenUnrecoverableError('Channel is not in a closed state') + elif detail.settle_block_number < self.client.block_number(): msg = ( 'updateNonClosingBalanceProof cannot be called ' 'because the settlement period is over' ) - log.critical(f'updateNonClosingBalanceProof failed, {msg}', **log_details) raise RaidenRecoverableError(msg) + else: + error_type, msg = self._check_channel_state_for_update( + channel_identifier=channel_identifier, + closer=partner, + update_nonce=nonce, + block_identifier=block_identifier, + ) + if error_type: + raise error_type(msg) - self._check_channel_state_for_update( + def update_transfer( + self, + channel_identifier: ChannelID, + partner: Address, + balance_hash: BalanceHash, + nonce: Nonce, + additional_hash: AdditionalHash, + closing_signature: Signature, + non_closing_signature: Signature, + ): + log_details = { + 'token_network': pex(self.address), + 'node': pex(self.node_address), + 'partner': pex(partner), + 'nonce': nonce, + 'balance_hash': encode_hex(balance_hash), + 'additional_hash': encode_hex(additional_hash), + 'closing_signature': encode_hex(closing_signature), + 'non_closing_signature': encode_hex(non_closing_signature), + } + log.debug('updateNonClosingBalanceProof called', **log_details) + + self._update_preconditions( channel_identifier=channel_identifier, - closer=partner, - update_nonce=nonce, - log_details=log_details, + partner=partner, + balance_hash=balance_hash, + nonce=nonce, + additional_hash=additional_hash, + closing_signature=closing_signature, + block_identifier='pending', ) - transaction_hash = self.proxy.transact( + error_prefix = 'updateNonClosingBalanceProof call will fail' + gas_limit = self.proxy.estimate_gas( + 'pending', 'updateNonClosingBalanceProof', - safe_gas_limit(GAS_REQUIRED_FOR_UPDATE_BALANCE_PROOF), channel_identifier, partner, self.node_address, @@ -826,48 +1094,87 @@ def update_transfer( non_closing_signature, ) - self.client.poll(transaction_hash) - - receipt_or_none = check_transaction_threw(self.client, transaction_hash) - if receipt_or_none: - if detail.settle_block_number < receipt_or_none['blockNumber']: - msg = ( - 'updateNonClosingBalanceProof transaction ' - 'was mined after settlement finished' - ) - log.critical(f'updateNonClosingBalanceProof failed, {msg}', **log_details) - raise RaidenRecoverableError(msg) - - self._check_channel_state_for_update( - channel_identifier=channel_identifier, - closer=partner, - update_nonce=nonce, - log_details=log_details, + if gas_limit: + error_prefix = 'updateNonClosingBalanceProof call failed' + transaction_hash = self.proxy.transact( + 'updateNonClosingBalanceProof', + safe_gas_limit(gas_limit, GAS_REQUIRED_FOR_UPDATE_BALANCE_PROOF), + channel_identifier, + partner, + self.node_address, + balance_hash, + nonce, + additional_hash, + closing_signature, + non_closing_signature, ) - # This should never happen if the settlement window and gas price - # estimation is done properly - channel_settled = self.channel_is_settled( + self.client.poll(transaction_hash) + receipt_or_none = check_transaction_threw(self.client, transaction_hash) + transaction_executed = gas_limit is not None + if not transaction_executed or receipt_or_none: + if transaction_executed: + block = receipt_or_none['blockNumber'] + to_compare_block = block + else: + block = 'pending' + to_compare_block = self.client.block_number() + + self.proxy.jsonrpc_client.check_for_insufficient_eth( + transaction_name='updateNonClosingBalanceProof', + transaction_executed=transaction_executed, + required_gas=GAS_REQUIRED_FOR_UPDATE_BALANCE_PROOF, + block_identifier=block, + ) + detail = self.detail_channel( participant1=self.node_address, participant2=partner, channel_identifier=channel_identifier, + block_identifier=block, ) - if channel_settled is True: - msg = 'Channel is settled' - log.critical(f'updateNonClosingBalanceProof failed, {msg}', **log_details) - raise RaidenRecoverableError(msg) - - msg = 'Update NonClosing balance proof' - log.critical(f'updateNonClosingBalanceProof failed, {msg}', **log_details) - raise TransactionThrew(msg, receipt_or_none) + if detail.settle_block_number < to_compare_block: + error_type = RaidenRecoverableError + msg = ( + 'updateNonClosingBalanceProof transaction ' + 'was mined after settlement finished' + ) + else: + error_type, msg = self._check_channel_state_for_update( + channel_identifier=channel_identifier, + closer=partner, + update_nonce=nonce, + block_identifier=block, + ) + if error_type is None: + # This should never happen if the settlement window and gas price + # estimation is done properly + channel_settled = self.channel_is_settled( + participant1=self.node_address, + participant2=partner, + channel_identifier=channel_identifier, + block_identifier=block, + ) + if channel_settled is True: + error_type = RaidenUnrecoverableError + msg = 'Channel is settled' + else: + error_type = RaidenUnrecoverableError + msg = '' + + error_msg = f'{error_prefix}. {msg}' + if error_type == RaidenRecoverableError: + log.warning(error_msg, **log_details) + else: + log.critical(error_msg, **log_details) + raise error_type(error_msg) log.info('updateNonClosingBalanceProof successful', **log_details) def unlock( self, - channel_identifier: typing.ChannelID, - partner: typing.Address, - merkle_tree_leaves: typing.MerkleTreeLeaves, + channel_identifier: ChannelID, + partner: Address, + merkle_tree_leaves: MerkleTreeLeaves, ): log_details = { 'token_network': pex(self.address), @@ -880,59 +1187,121 @@ def unlock( log.info('skipping unlock, tree is empty', **log_details) return - log.info('unlock called', **log_details) - leaves_packed = b''.join(lock.encoded for lock in merkle_tree_leaves) + error_prefix = 'Call to unlock will fail' gas_limit = self.proxy.estimate_gas( + 'pending', 'unlock', channel_identifier, self.node_address, partner, leaves_packed, ) - gas_limit = safe_gas_limit(gas_limit, UNLOCK_TX_GAS_LIMIT) - transaction_hash = self.proxy.transact( - 'unlock', - gas_limit, - channel_identifier, - self.node_address, - partner, - leaves_packed, - ) + if gas_limit: + gas_limit = safe_gas_limit(gas_limit, UNLOCK_TX_GAS_LIMIT) + error_prefix = 'Call to unlock failed' + log.info('unlock called', **log_details) + transaction_hash = self.proxy.transact( + 'unlock', + gas_limit, + channel_identifier, + self.node_address, + partner, + leaves_packed, + ) + + self.client.poll(transaction_hash) + receipt_or_none = check_transaction_threw(self.client, transaction_hash) - self.client.poll(transaction_hash) - receipt_or_none = check_transaction_threw(self.client, transaction_hash) + transaction_executed = gas_limit is not None + if not transaction_executed or receipt_or_none: + if transaction_executed: + block = receipt_or_none['blockNumber'] + else: + block = 'pending' - if receipt_or_none: + self.proxy.jsonrpc_client.check_for_insufficient_eth( + transaction_name='unlock', + transaction_executed=transaction_executed, + required_gas=UNLOCK_TX_GAS_LIMIT, + block_identifier=block, + ) channel_settled = self.channel_is_settled( participant1=self.node_address, participant2=partner, channel_identifier=channel_identifier, + block_identifier=block, ) - if channel_settled is False: - log.critical('unlock failed. Channel is not in a settled state', **log_details) - raise RaidenUnrecoverableError( - 'Channel is not in a settled state. An unlock cannot be made', - ) + msg = 'Channel is not in a settled state' - log.critical('unlock failed', **log_details) - raise TransactionThrew('Unlock', receipt_or_none) + error_msg = f'{error_prefix}. {msg}' + + log.critical(error_msg, **log_details) + raise RaidenUnrecoverableError(error_msg) log.info('unlock successful', **log_details) + def _settle_preconditions( + self, + channel_identifier: ChannelID, + transferred_amount: TokenAmount, + locked_amount: TokenAmount, + locksroot: Locksroot, + partner: Address, + partner_transferred_amount: TokenAmount, + partner_locked_amount: TokenAmount, + partner_locksroot: Locksroot, + block_identifier: BlockSpecification, + ): + self._check_for_outdated_channel( + participant1=self.node_address, + participant2=partner, + channel_identifier=channel_identifier, + block_identifier=block_identifier, + ) + + # and now find out + our_maximum = transferred_amount + locked_amount + partner_maximum = partner_transferred_amount + partner_locked_amount + + # The second participant transferred + locked amount must be higher + our_bp_is_larger = our_maximum > partner_maximum + if our_bp_is_larger: + return [ + partner, + partner_transferred_amount, + partner_locked_amount, + partner_locksroot, + self.node_address, + transferred_amount, + locked_amount, + locksroot, + ] + else: + return [ + self.node_address, + transferred_amount, + locked_amount, + locksroot, + partner, + partner_transferred_amount, + partner_locked_amount, + partner_locksroot, + ] + def settle( self, - channel_identifier: typing.ChannelID, - transferred_amount: int, - locked_amount: int, - locksroot: typing.Locksroot, - partner: typing.Address, - partner_transferred_amount: int, - partner_locked_amount: int, - partner_locksroot: typing.Locksroot, + channel_identifier: ChannelID, + transferred_amount: TokenAmount, + locked_amount: TokenAmount, + locksroot: Locksroot, + partner: Address, + partner_transferred_amount: TokenAmount, + partner_locked_amount: TokenAmount, + partner_locksroot: Locksroot, ): """ Settle the channel. """ log_details = { @@ -949,94 +1318,70 @@ def settle( } log.debug('settle called', **log_details) - self._check_for_outdated_channel( - self.node_address, - partner, - channel_identifier, + args = self._settle_preconditions( + channel_identifier=channel_identifier, + transferred_amount=transferred_amount, + locked_amount=locked_amount, + locksroot=locksroot, + partner=partner, + partner_transferred_amount=partner_transferred_amount, + partner_locked_amount=partner_locked_amount, + partner_locksroot=partner_locksroot, + block_identifier='pending', ) with self.channel_operations_lock[partner]: - our_maximum = transferred_amount + locked_amount - partner_maximum = partner_transferred_amount + partner_locked_amount - - # The second participant transferred + locked amount must be higher - our_bp_is_larger = our_maximum > partner_maximum + error_prefix = 'Call to settle will fail' + gas_limit = self.proxy.estimate_gas( + 'pending', + 'settleChannel', + channel_identifier, + *args, + ) - if our_bp_is_larger: - gas_limit = self.proxy.estimate_gas( - 'settleChannel', - channel_identifier, - partner, - partner_transferred_amount, - partner_locked_amount, - partner_locksroot, - self.node_address, - transferred_amount, - locked_amount, - locksroot, - ) + if gas_limit: + error_prefix = 'settle call failed' gas_limit = safe_gas_limit(gas_limit, GAS_REQUIRED_FOR_SETTLE_CHANNEL) transaction_hash = self.proxy.transact( 'settleChannel', gas_limit, channel_identifier, - partner, - partner_transferred_amount, - partner_locked_amount, - partner_locksroot, - self.node_address, - transferred_amount, - locked_amount, - locksroot, + *args, ) - else: - gas_limit = self.proxy.estimate_gas( - 'settleChannel', - channel_identifier, - self.node_address, - transferred_amount, - locked_amount, - locksroot, - partner, - partner_transferred_amount, - partner_locked_amount, - partner_locksroot, - ) - gas_limit = safe_gas_limit(gas_limit, GAS_REQUIRED_FOR_SETTLE_CHANNEL) + self.client.poll(transaction_hash) + receipt_or_none = check_transaction_threw(self.client, transaction_hash) - transaction_hash = self.proxy.transact( - 'settleChannel', - gas_limit, - channel_identifier, - self.node_address, - transferred_amount, - locked_amount, - locksroot, - partner, - partner_transferred_amount, - partner_locked_amount, - partner_locksroot, - ) + transaction_executed = gas_limit is not None + if not transaction_executed or receipt_or_none: + if transaction_executed: + block = receipt_or_none['blockNumber'] + else: + block = 'pending' - self.client.poll(transaction_hash) - receipt_or_none = check_transaction_threw(self.client, transaction_hash) - if receipt_or_none: - log.critical('settle failed', **log_details) - self._check_channel_state_for_settle( - self.node_address, - partner, - channel_identifier, - ) - raise TransactionThrew('Settle', receipt_or_none) + self.proxy.jsonrpc_client.check_for_insufficient_eth( + transaction_name='settleChannel', + transaction_executed=transaction_executed, + required_gas=GAS_REQUIRED_FOR_SETTLE_CHANNEL, + block_identifier=block, + ) + msg = self._check_channel_state_after_settle( + participant1=self.node_address, + participant2=partner, + channel_identifier=channel_identifier, + block_identifier=block, + ) + error_msg = f'{error_prefix}. {msg}' + log.critical(error_msg, **log_details) + raise RaidenUnrecoverableError(error_msg) - log.info('settle successful', **log_details) + log.info('settle successful', **log_details) def events_filter( self, topics: List[str] = None, - from_block: typing.BlockSpecification = None, - to_block: typing.BlockSpecification = None, + from_block: BlockSpecification = None, + to_block: BlockSpecification = None, ) -> StatelessFilter: """ Install a new filter for an array of topics emitted by the contract. @@ -1057,8 +1402,8 @@ def events_filter( def all_events_filter( self, - from_block: typing.BlockSpecification = GENESIS_BLOCK_NUMBER, - to_block: typing.BlockSpecification = 'latest', + from_block: BlockSpecification = GENESIS_BLOCK_NUMBER, + to_block: BlockSpecification = 'latest', ) -> StatelessFilter: """ Install a new filter for all the events emitted by the current token network contract @@ -1073,18 +1418,20 @@ def all_events_filter( def _check_for_outdated_channel( self, - participant1: typing.Address, - participant2: typing.Address, - channel_identifier: typing.ChannelID, + participant1: Address, + participant2: Address, + channel_identifier: ChannelID, + block_identifier: BlockSpecification, ) -> None: """ - Checks whether an operation is being execute on a channel + Checks whether an operation is being executed on a channel between two participants using an old channel identifier """ try: onchain_channel_details = self.detail_channel( - participant1, - participant2, + participant1=participant1, + participant2=participant2, + block_identifier=block_identifier, ) except RaidenRecoverableError: return @@ -1100,89 +1447,71 @@ def _check_for_outdated_channel( def _get_channel_state( self, - participant1: typing.Address, - participant2: typing.Address, - channel_identifier: typing.ChannelID = None, - ) -> typing.ChannelState: - channel_data = self.detail_channel(participant1, participant2, channel_identifier) + participant1: Address, + participant2: Address, + channel_identifier: ChannelID = None, + block_identifier: BlockSpecification = 'pending', + ) -> ChannelState: + channel_data = self.detail_channel( + participant1=participant1, + participant2=participant2, + channel_identifier=channel_identifier, + block_identifier=block_identifier, + ) - if not isinstance(channel_data.state, typing.T_ChannelState): + if not isinstance(channel_data.state, T_ChannelState): raise ValueError('channel state must be of type ChannelState') return channel_data.state def _check_channel_state_for_close( self, - participant1: typing.Address, - participant2: typing.Address, - channel_identifier: typing.ChannelID, - ) -> None: + participant1: Address, + participant2: Address, + channel_identifier: ChannelID, + block_identifier: BlockSpecification, + ) -> Tuple[ + Optional[Union[RaidenRecoverableError, RaidenUnrecoverableError]], + str, + ]: + error_type = None + msg = '' channel_state = self._get_channel_state( participant1=participant1, participant2=participant2, channel_identifier=channel_identifier, + block_identifier=block_identifier, ) if channel_state in (ChannelState.NONEXISTENT, ChannelState.REMOVED): - raise RaidenUnrecoverableError( + error_type = RaidenUnrecoverableError + msg = ( f'Channel between participant {participant1} ' f'and {participant2} does not exist', ) elif channel_state == ChannelState.SETTLED: - raise RaidenUnrecoverableError( - 'A settled channel cannot be closed', - ) + error_type = RaidenUnrecoverableError + msg = 'A settled channel cannot be closed' elif channel_state == ChannelState.CLOSED: - raise RaidenRecoverableError( - 'Channel is already closed', - ) + error_type = RaidenRecoverableError + msg = 'Channel is already closed' + + return error_type, msg - def _check_channel_state_for_deposit( + def _check_channel_state_before_settle( self, - participant1: typing.Address, - participant2: typing.Address, - channel_identifier: typing.ChannelID, - deposit_amount: typing.TokenAmount, - ) -> None: - participant_details = self.detail_participants( - participant1, - participant2, - channel_identifier, - ) + participant1: Address, + participant2: Address, + channel_identifier: ChannelID, + block_identifier: BlockSpecification, + ) -> ChannelData: - channel_state = self._get_channel_state( - participant1=self.node_address, + channel_data = self.detail_channel( + participant1=participant1, participant2=participant2, channel_identifier=channel_identifier, + block_identifier=block_identifier, ) - # Check if deposit is being made on a nonexistent channel - if channel_state in (ChannelState.NONEXISTENT, ChannelState.REMOVED): - raise RaidenUnrecoverableError( - f'Channel between participant {participant1} ' - f'and {participant2} does not exist', - ) - # Deposit was prohibited because the channel is settled - elif channel_state == ChannelState.SETTLED: - raise RaidenUnrecoverableError( - 'Deposit is not possible due to channel being settled', - ) - # Deposit was prohibited because the channel is closed - elif channel_state == ChannelState.CLOSED: - raise RaidenRecoverableError( - 'Channel is already closed', - ) - elif participant_details.our_details.deposit < deposit_amount: - raise RaidenUnrecoverableError( - 'Deposit amount did not increase after deposit transaction', - ) - - def _check_channel_state_for_settle( - self, - participant1: typing.Address, - participant2: typing.Address, - channel_identifier: typing.ChannelID, - ) -> None: - channel_data = self.detail_channel(participant1, participant2, channel_identifier) if channel_data.state == ChannelState.SETTLED: raise RaidenRecoverableError( 'Channel is already settled', @@ -1201,32 +1530,54 @@ def _check_channel_state_for_settle( 'Channel cannot be settled before settlement window is over', ) - raise RaidenUnrecoverableError( + return channel_data + + def _check_channel_state_after_settle( + self, + participant1: Address, + participant2: Address, + channel_identifier: ChannelID, + block_identifier: BlockSpecification, + ) -> str: + msg = '' + channel_data = self._check_channel_state_before_settle( + participant1=participant1, + participant2=participant2, + channel_identifier=channel_identifier, + block_identifier=block_identifier, + ) + if channel_data.state == ChannelState.CLOSED: + msg = ( "Settling this channel failed although the channel's current state " "is closed.", ) + return msg def _check_channel_state_for_update( self, - channel_identifier: typing.ChannelID, - closer: typing.Address, - update_nonce: typing.Nonce, - log_details: typing.Dict, - ) -> None: + channel_identifier: ChannelID, + closer: Address, + update_nonce: Nonce, + block_identifier: BlockSpecification, + ) -> Tuple[Optional[RaidenRecoverableError], str]: """Check the channel state on chain to see if it has been updated. Compare the nonce we are about to update the contract with the updated nonce in the onchain state and if it's the same raise a RaidenRecoverableError""" + error_type = None + msg = '' closer_details = self.detail_participant( channel_identifier=channel_identifier, participant=closer, partner=self.node_address, + block_identifier=block_identifier, ) if closer_details.nonce == update_nonce: + error_type = RaidenRecoverableError msg = ( 'updateNonClosingBalanceProof transaction has already ' 'been mined and updated the channel succesfully.' ) - log.warning(f'{msg}', **log_details) - raise RaidenRecoverableError(msg) + + return error_type, msg diff --git a/raiden/network/proxies/token_network_registry.py b/raiden/network/proxies/token_network_registry.py index 03ad27b581..6187c1fc68 100644 --- a/raiden/network/proxies/token_network_registry.py +++ b/raiden/network/proxies/token_network_registry.py @@ -16,11 +16,18 @@ GENESIS_BLOCK_NUMBER, NULL_ADDRESS, ) -from raiden.exceptions import InvalidAddress, RaidenRecoverableError, TransactionThrew +from raiden.exceptions import InvalidAddress, RaidenRecoverableError, RaidenUnrecoverableError from raiden.network.proxies.utils import compare_contract_versions from raiden.network.rpc.client import StatelessFilter, check_address_has_code from raiden.network.rpc.transactions import check_transaction_threw -from raiden.utils import pex, privatekey_to_address, safe_gas_limit, typing +from raiden.utils import pex, privatekey_to_address, safe_gas_limit +from raiden.utils.typing import ( + Address, + BlockSpecification, + PaymentNetworkID, + T_TargetAddress, + TokenAddress, +) from raiden_contracts.constants import CONTRACT_TOKEN_NETWORK_REGISTRY, EVENT_TOKEN_NETWORK_CREATED from raiden_contracts.contract_manager import ContractManager @@ -31,13 +38,17 @@ class TokenNetworkRegistry: def __init__( self, jsonrpc_client, - registry_address, + registry_address: PaymentNetworkID, contract_manager: ContractManager, ): if not is_binary_address(registry_address): raise InvalidAddress('Expected binary address format for token network registry') - check_address_has_code(jsonrpc_client, registry_address, CONTRACT_TOKEN_NETWORK_REGISTRY) + check_address_has_code( + client=jsonrpc_client, + address=Address(registry_address), + contract_name=CONTRACT_TOKEN_NETWORK_REGISTRY, + ) self.contract_manager = contract_manager proxy = jsonrpc_client.new_contract_proxy( @@ -49,7 +60,7 @@ def __init__( proxy=proxy, expected_version=contract_manager.contracts_version, contract_name=CONTRACT_TOKEN_NETWORK_REGISTRY, - address=registry_address, + address=Address(registry_address), ) self.address = registry_address @@ -57,11 +68,15 @@ def __init__( self.client = jsonrpc_client self.node_address = privatekey_to_address(self.client.privkey) - def get_token_network(self, token_address: typing.TokenAddress) -> Optional[typing.Address]: + def get_token_network( + self, + token_address: TokenAddress, + block_identifier: BlockSpecification = 'latest', + ) -> Optional[Address]: """ Return the token network address for the given token or None if there is no correspoding address. """ - if not isinstance(token_address, typing.T_TargetAddress): + if not isinstance(token_address, T_TargetAddress): raise ValueError('token_address must be an address') address = self.proxy.contract.functions.token_to_token_networks( @@ -74,7 +89,7 @@ def get_token_network(self, token_address: typing.TokenAddress) -> Optional[typi return address - def add_token(self, token_address: typing.TokenAddress): + def add_token(self, token_address: TokenAddress): if not is_binary_address(token_address): raise InvalidAddress('Expected binary address format for token') @@ -85,32 +100,57 @@ def add_token(self, token_address: typing.TokenAddress): } log.debug('createERC20TokenNetwork called', **log_details) - transaction_hash = self.proxy.transact( + error_prefix = 'Call to createERC20TokenNetwork will fail' + gas_limit = self.proxy.estimate_gas( + 'pending', 'createERC20TokenNetwork', - safe_gas_limit(GAS_REQUIRED_FOR_CREATE_ERC20_TOKEN_NETWORK), token_address, ) - self.client.poll(transaction_hash) - receipt_or_none = check_transaction_threw(self.client, transaction_hash) - if receipt_or_none: - if self.get_token_network(token_address): - msg = 'Token already registered' - log.info(f'createERC20TokenNetwork failed, {msg}', **log_details) - raise RaidenRecoverableError(msg) + if gas_limit: + error_prefix = 'Call to createERC20TokenNetwork failed' + transaction_hash = self.proxy.transact( + 'createERC20TokenNetwork', + safe_gas_limit(gas_limit, GAS_REQUIRED_FOR_CREATE_ERC20_TOKEN_NETWORK), + token_address, + ) - log.critical(f'createERC20TokenNetwork failed', **log_details) - raise TransactionThrew('createERC20TokenNetwork', receipt_or_none) + self.client.poll(transaction_hash) + receipt_or_none = check_transaction_threw(self.client, transaction_hash) + + transaction_executed = gas_limit is not None + if not transaction_executed or receipt_or_none: + error_type = RaidenUnrecoverableError + if transaction_executed: + block = receipt_or_none['blockNumber'] + else: + block = 'pending' + + required_gas = gas_limit if gas_limit else GAS_REQUIRED_FOR_CREATE_ERC20_TOKEN_NETWORK + self.proxy.jsonrpc_client.check_for_insufficient_eth( + transaction_name='createERC20TokenNetwork', + transaction_executed=transaction_executed, + required_gas=required_gas, + block_identifier=block, + ) - token_network_address = self.get_token_network(token_address) + msg = '' + if self.get_token_network(token_address, block): + msg = 'Token already registered' + error_type = RaidenRecoverableError - if token_network_address is None: - log.critical( - 'createERC20TokenNetwork failed and check_transaction_threw didnt detect it', - **log_details, - ) + error_msg = f'{error_prefix}. {msg}' + if error_type == RaidenRecoverableError: + log.warning(error_msg, **log_details) + else: + log.critical(error_msg, **log_details) + raise error_type(error_msg) - raise RuntimeError('token_to_token_networks failed') + token_network_address = self.get_token_network(token_address, 'latest') + if token_network_address is None: + msg = 'createERC20TokenNetwork succeeded but token network address is Null' + log.critical(msg, **log_details) + raise RuntimeError(msg) log.info( 'createERC20TokenNetwork successful', @@ -122,8 +162,8 @@ def add_token(self, token_address: typing.TokenAddress): def tokenadded_filter( self, - from_block: typing.BlockSpecification = GENESIS_BLOCK_NUMBER, - to_block: typing.BlockSpecification = 'latest', + from_block: BlockSpecification = GENESIS_BLOCK_NUMBER, + to_block: BlockSpecification = 'latest', ) -> StatelessFilter: event_abi = self.contract_manager.get_event_abi( CONTRACT_TOKEN_NETWORK_REGISTRY, diff --git a/raiden/network/rpc/client.py b/raiden/network/rpc/client.py index f2bbd9882a..6cec786f85 100644 --- a/raiden/network/rpc/client.py +++ b/raiden/network/rpc/client.py @@ -1,12 +1,14 @@ import copy import os import warnings +from typing import Any, Callable, Dict, List, Optional, Tuple import gevent import structlog from eth_utils import ( decode_hex, encode_hex, + is_checksum_address, remove_0x_prefix, to_canonical_address, to_checksum_address, @@ -14,20 +16,38 @@ from gevent.lock import Semaphore from requests import ConnectTimeout from web3 import Web3 +from web3.contract import ContractFunction +from web3.eth import Eth from web3.gas_strategies.rpc import rpc_gas_price_strategy from web3.middleware import geth_poa_middleware +from web3.utils.contracts import prepare_transaction +from web3.utils.empty import empty +from web3.utils.toolz import assoc from raiden import constants -from raiden.exceptions import AddressWithoutCode, EthNodeCommunicationError, EthNodeInterfaceError +from raiden.exceptions import ( + AddressWithoutCode, + EthNodeCommunicationError, + EthNodeInterfaceError, + InsufficientFunds, +) from raiden.network.rpc.middleware import block_hash_cache_middleware, connection_test_middleware from raiden.network.rpc.smartcontract_proxy import ContractProxy -from raiden.utils import is_supported_client, pex, privatekey_to_address, typing +from raiden.utils import is_supported_client, pex, privatekey_to_address from raiden.utils.filters import StatelessFilter from raiden.utils.solc import ( solidity_library_symbol, solidity_resolve_symbols, solidity_unresolved_symbols, ) +from raiden.utils.typing import ( + ABI, + Address, + AddressHex, + BlockSpecification, + Nonce, + TransactionHash, +) log = structlog.get_logger(__name__) # pylint: disable=invalid-name @@ -107,8 +127,8 @@ def parity_assert_rpc_interfaces(web3: Web3): def parity_discover_next_available_nonce( web3: Web3, - address: typing.AddressHex, -) -> typing.Nonce: + address: AddressHex, +) -> Nonce: """Returns the next available nonce for `address`.""" next_nonce_encoded = web3.manager.request_blocking('parity_nextNonce', [address]) return int(next_nonce_encoded, 16) @@ -116,8 +136,8 @@ def parity_discover_next_available_nonce( def geth_discover_next_available_nonce( web3: Web3, - address: typing.AddressHex, -) -> typing.Nonce: + address: AddressHex, +) -> Nonce: """Returns the next available nonce for `address`.""" # The nonces of the mempool transactions are considered used, and it's @@ -149,7 +169,7 @@ def geth_discover_next_available_nonce( def check_address_has_code( client: 'JSONRPCClient', - address: typing.Address, + address: Address, contract_name: str = '', ): """ Checks that the given address contains code. """ @@ -222,6 +242,94 @@ def dependencies_order_of_build(target_contract, dependencies_map): return order +def patched_web3_eth_estimate_gas(self, transaction, block_identifier=None): + """ Temporary workaround until next web3.py release (5.X.X) + + Current master of web3.py has this implementation already: + https://github.com/ethereum/web3.py/blob/2a67ea9f0ab40bb80af2b803dce742d6cad5943e/web3/eth.py#L311 + """ + if 'from' not in transaction and is_checksum_address(self.defaultAccount): + transaction = assoc(transaction, 'from', self.defaultAccount) + + if block_identifier is None: + params = [transaction] + else: + params = [transaction, block_identifier] + + return self.web3.manager.request_blocking( + 'eth_estimateGas', + params, + ) + + +def estimate_gas_for_function( + address, + web3, + fn_identifier=None, + transaction=None, + contract_abi=None, + fn_abi=None, + block_identifier=None, + *args, + **kwargs, +): + """Temporary workaround until next web3.py release (5.X.X)""" + estimate_transaction = prepare_transaction( + address, + web3, + fn_identifier=fn_identifier, + contract_abi=contract_abi, + fn_abi=fn_abi, + transaction=transaction, + fn_args=args, + fn_kwargs=kwargs, + ) + + gas_estimate = web3.eth.estimateGas(estimate_transaction, block_identifier) + return gas_estimate + + +def patched_contractfunction_estimateGas(self, transaction=None, block_identifier=None): + """Temporary workaround until next web3.py release (5.X.X)""" + if transaction is None: + estimate_gas_transaction = {} + else: + estimate_gas_transaction = dict(**transaction) + + if 'data' in estimate_gas_transaction: + raise ValueError('Cannot set data in estimateGas transaction') + if 'to' in estimate_gas_transaction: + raise ValueError('Cannot set to in estimateGas transaction') + + if self.address: + estimate_gas_transaction.setdefault('to', self.address) + if self.web3.eth.defaultAccount is not empty: + estimate_gas_transaction.setdefault('from', self.web3.eth.defaultAccount) + + if 'to' not in estimate_gas_transaction: + if isinstance(self, type): + raise ValueError( + 'When using `Contract.estimateGas` from a contract factory ' + 'you must provide a `to` address with the transaction', + ) + else: + raise ValueError( + 'Please ensure that this contract instance has an address.', + ) + + return estimate_gas_for_function( + self.address, + self.web3, + self.function_identifier, + estimate_gas_transaction, + self.contract_abi, + self.abi, + block_identifier, + *self.args, + **self.kwargs, + ) + + def monkey_patch_web3(web3, gas_price_strategy): try: # install caching middleware @@ -242,6 +350,10 @@ def monkey_patch_web3(web3, gas_price_strategy): if not hasattr(web3, 'testing'): web3.middleware_stack.inject(connection_test_middleware, layer=0) + # Temporary until next web3.py release (5.X.X) + ContractFunction.estimateGas = patched_contractfunction_estimateGas + Eth.estimateGas = patched_web3_eth_estimate_gas + class JSONRPCClient: """ Ethereum JSON RPC client. @@ -256,7 +368,7 @@ def __init__( self, web3: Web3, privkey: bytes, - gas_price_strategy: typing.Callable = rpc_gas_price_strategy, + gas_price_strategy: Callable = rpc_gas_price_strategy, block_num_confirmations: int = 0, uses_infura=False, ): @@ -334,15 +446,15 @@ def block_number(self): """ Return the most recent block. """ return self.web3.eth.blockNumber - def balance(self, account: typing.Address): + def balance(self, account: Address): """ Return the balance of the account of given address. """ return self.web3.eth.getBalance(to_checksum_address(account), 'pending') def parity_get_pending_transaction_hash_by_nonce( self, - checksummed_address: typing.AddressHex, - nonce: typing.Nonce, - ) -> typing.Optional[typing.TransactionHash]: + checksummed_address: AddressHex, + nonce: Nonce, + ) -> Optional[TransactionHash]: """Queries the local parity transaction pool and searches for a transaction. Checks the local tx pool for a transaction from a particular address and for @@ -373,7 +485,7 @@ def gas_price(self) -> int: return price - def new_contract_proxy(self, contract_interface, contract_address: typing.Address): + def new_contract_proxy(self, contract_interface, contract_address: Address): """ Return a proxy for interacting with a smart contract. Args: @@ -385,7 +497,7 @@ def new_contract_proxy(self, contract_interface, contract_address: typing.Addres contract=self.new_contract(contract_interface, contract_address), ) - def new_contract(self, contract_interface: typing.Dict, contract_address: typing.Address): + def new_contract(self, contract_interface: Dict, contract_address: Address): return self.web3.eth.contract( abi=contract_interface, address=to_checksum_address(contract_address), @@ -397,9 +509,9 @@ def get_transaction_receipt(self, tx_hash: bytes): def deploy_solidity_contract( self, # pylint: disable=too-many-locals contract_name: str, - all_contracts: typing.Dict[str, typing.ABI], - libraries: typing.Dict[str, typing.Address] = None, - constructor_parameters: typing.Tuple[typing.Any] = None, + all_contracts: Dict[str, ABI], + libraries: Dict[str, Address] = None, + constructor_parameters: Tuple[Any] = None, contract_path: str = None, ): """ @@ -470,7 +582,7 @@ def deploy_solidity_contract( gas_limit = self.web3.eth.getBlock('latest')['gasLimit'] * 8 // 10 transaction_hash = self.send_transaction( - to=typing.Address(b''), + to=Address(b''), startgas=gas_limit, data=bytecode, ) @@ -503,7 +615,7 @@ def deploy_solidity_contract( contract = self.web3.eth.contract(abi=contract['abi'], bytecode=contract['bin']) contract_transaction = contract.constructor(*constructor_parameters).buildTransaction() transaction_hash = self.send_transaction( - to=typing.Address(b''), + to=Address(b''), data=contract_transaction['data'], startgas=contract_transaction['gas'], ) @@ -528,7 +640,7 @@ def deploy_solidity_contract( def send_transaction( self, - to: typing.Address, + to: Address, startgas: int, value: int = 0, data: bytes = b'', @@ -632,10 +744,10 @@ def poll( def new_filter( self, - contract_address: typing.Address, - topics: typing.List[str] = None, - from_block: typing.BlockSpecification = 0, - to_block: typing.BlockSpecification = 'latest', + contract_address: Address, + topics: List[str] = None, + from_block: BlockSpecification = 0, + to_block: BlockSpecification = 'latest', ) -> StatelessFilter: """ Create a filter in the ethereum node. """ return StatelessFilter( @@ -650,11 +762,11 @@ def new_filter( def get_filter_events( self, - contract_address: typing.Address, - topics: typing.List[str] = None, - from_block: typing.BlockSpecification = 0, - to_block: typing.BlockSpecification = 'latest', - ) -> typing.List[typing.Dict]: + contract_address: Address, + topics: List[str] = None, + from_block: BlockSpecification = 0, + to_block: BlockSpecification = 'latest', + ) -> List[Dict]: """ Get events for the given query. """ return self.web3.eth.getLogs({ 'fromBlock': from_block, @@ -662,3 +774,26 @@ def get_filter_events( 'address': to_checksum_address(contract_address), 'topics': topics, }) + + def check_for_insufficient_eth( + self, + transaction_name: str, + transaction_executed: bool, + required_gas: int, + block_identifier: BlockSpecification, + ): + """ After estimate gas failure checks if our address has enough balance. + + If the account did not have enough ETH balance to execute the, + transaction then it raises an `InsufficientFunds` error + """ + if transaction_executed: + return + + our_address = to_checksum_address(self.address) + balance = self.web3.eth.getBalance(our_address, block_identifier) + required_balance = required_gas * self.gas_price() + if balance < required_balance: + msg = f'Failed to execute {transaction_name} due to insufficient ETH' + log.critical(msg, required_wei=required_balance, actual_wei=balance) + raise InsufficientFunds(msg) diff --git a/raiden/network/rpc/smartcontract_proxy.py b/raiden/network/rpc/smartcontract_proxy.py index b030a8b124..92657d1139 100644 --- a/raiden/network/rpc/smartcontract_proxy.py +++ b/raiden/network/rpc/smartcontract_proxy.py @@ -7,6 +7,7 @@ from web3.utils.abi import get_abi_input_types from web3.utils.contracts import encode_transaction_data, find_matching_fn_abi +from raiden import constants from raiden.constants import EthClient from raiden.exceptions import ( InsufficientFunds, @@ -181,12 +182,35 @@ def decode_event(self, log: Dict): def encode_function_call(self, function: str, args: List = None): return self.get_transaction_data(self.contract.abi, function, args) - def estimate_gas(self, function: str, *args): + def estimate_gas(self, block_identifier, function: str, *args) -> typing.Optional[int]: + """Returns a gas estimate for the function with the given arguments or + None if the function call will fail due to Insufficient funds or + the logic in the called function.""" + msg = ( + 'At the moment since geth only accepts pending block for estimateGas ' + 'we enforce the same behaviour for all clients. Please use "pending".' + ) + assert block_identifier == 'pending', msg fn = getattr(self.contract.functions, function) + address = to_checksum_address(self.jsonrpc_client.address) + if self.jsonrpc_client.eth_node == constants.EthClient.GETH: + # Unfortunately geth does not follow the ethereum JSON-RPC spec and + # does not accept a block identifier argument for eth_estimateGas + # parity and py-evm (trinity) do. + # + # Geth only runs estimateGas on the pending block and that's why we + # should also enforce parity, py-evm and others to do the same since + # we can't customize geth. + # + # Spec: https://github.com/ethereum/wiki/wiki/JSON-RPC#eth_estimategas + # Geth Issue: https://github.com/ethereum/go-ethereum/issues/2586 + # Relevant web3 PR: https://github.com/ethereum/web3.py/pull/1046 + block_identifier = None try: - return fn(*args).estimateGas({ - 'from': to_checksum_address(self.jsonrpc_client.address), - }) + return fn(*args).estimateGas( + transaction={'from': address}, + block_identifier=block_identifier, + ) except ValueError as err: action = inspect_client_error(err, self.jsonrpc_client.eth_node) will_fail = action in ( diff --git a/raiden/tests/integration/contracts/test_secret_registry.py b/raiden/tests/integration/contracts/test_secret_registry.py index 1db94be4de..eacf36479d 100644 --- a/raiden/tests/integration/contracts/test_secret_registry.py +++ b/raiden/tests/integration/contracts/test_secret_registry.py @@ -53,14 +53,14 @@ def secret_registry_proxy_patched(secret_registry_proxy, contract_manager): secret_registry_address=secret_registry_proxy.address, contract_manager=contract_manager, ) - _register_secret_batch = secret_registry_patched._register_secret_batch + register_secret_batch = secret_registry_patched.register_secret_batch def register_secret_batch_patched(self, secrets): """Make sure the transaction is sent only once per secret""" for secret in secrets: assert secret not in self.trigger self.trigger[secret] = True - return _register_secret_batch(secrets) + return register_secret_batch(secrets) secret_registry_patched._register_secret_batch = types.MethodType( register_secret_batch_patched, diff --git a/raiden/tests/integration/contracts/test_token.py b/raiden/tests/integration/contracts/test_token.py index edb855c160..3f22a5b0b9 100644 --- a/raiden/tests/integration/contracts/test_token.py +++ b/raiden/tests/integration/contracts/test_token.py @@ -31,6 +31,6 @@ def test_token( assert allow_funds == token_proxy.proxy.contract.functions.allowance( to_checksum_address(deploy_client.address), to_checksum_address(address), - ).call() + ).call(block_identifier='latest') other_token_proxy.transfer(deploy_client.address, transfer_funds) assert token_proxy.balance_of(address) == 0 diff --git a/raiden/tests/integration/contracts/test_token_network.py b/raiden/tests/integration/contracts/test_token_network.py index b3c888b81f..0df5d096f6 100644 --- a/raiden/tests/integration/contracts/test_token_network.py +++ b/raiden/tests/integration/contracts/test_token_network.py @@ -9,7 +9,6 @@ RaidenRecoverableError, RaidenUnrecoverableError, SamePeerAddress, - TransactionThrew, ) from raiden.network.proxies import TokenNetwork from raiden.network.rpc.client import JSONRPCClient @@ -104,6 +103,7 @@ def test_token_network_proxy_basics( ) is False channel_identifier = c1_token_network_proxy._call_and_check_result( + 'latest', 'getChannelIdentifier', to_checksum_address(c1_client.address), to_checksum_address(c2_client.address), @@ -219,7 +219,7 @@ def test_token_network_proxy_basics( data=balance_proof.serialize_bin(), )) # close with invalid signature - with pytest.raises(TransactionThrew): + with pytest.raises(RaidenUnrecoverableError): c2_token_network_proxy.close( channel_identifier=channel_identifier, partner=c1_client.address, @@ -436,7 +436,7 @@ def test_token_network_proxy_update_transfer( privkey=encode_hex(c2_client.privkey), data=non_closing_data, ) - with pytest.raises(TransactionThrew): + with pytest.raises(RaidenUnrecoverableError): c2_token_network_proxy.update_transfer( channel_identifier, c1_client.address, diff --git a/raiden/tests/integration/contracts/test_token_network_registry.py b/raiden/tests/integration/contracts/test_token_network_registry.py index b7f4e4b587..25168c199b 100644 --- a/raiden/tests/integration/contracts/test_token_network_registry.py +++ b/raiden/tests/integration/contracts/test_token_network_registry.py @@ -1,7 +1,7 @@ import pytest from eth_utils import is_same_address, to_canonical_address -from raiden.exceptions import RaidenRecoverableError, TransactionThrew +from raiden.exceptions import RaidenRecoverableError, RaidenUnrecoverableError from raiden.network.proxies.token_network_registry import TokenNetworkRegistry from raiden.tests.utils.factories import make_address from raiden.tests.utils.smartcontracts import deploy_token @@ -19,7 +19,7 @@ def test_token_network_registry( bad_token_address = make_address() # try to register non-existing token network - with pytest.raises(TransactionThrew): + with pytest.raises(RaidenUnrecoverableError): token_network_registry_proxy.add_token(bad_token_address) # create token network & register it test_token = deploy_token( diff --git a/raiden/tests/integration/rpc/test_assumptions.py b/raiden/tests/integration/rpc/test_assumptions.py index cc5a5055ba..d52f52f57a 100644 --- a/raiden/tests/integration/rpc/test_assumptions.py +++ b/raiden/tests/integration/rpc/test_assumptions.py @@ -109,7 +109,7 @@ def test_estimate_gas_fail(deploy_client): address = contract_proxy.contract_address assert len(deploy_client.web3.eth.getCode(to_checksum_address(address))) > 0 - assert not contract_proxy.estimate_gas('fail') + assert not contract_proxy.estimate_gas('pending', 'fail') def test_duplicated_transaction_same_gas_price_raises(deploy_client): @@ -132,7 +132,7 @@ def test_duplicated_transaction_same_gas_price_raises(deploy_client): contract_proxy.contract_address, ) - startgas = safe_gas_limit(contract_proxy.estimate_gas('ret')) + startgas = safe_gas_limit(contract_proxy.estimate_gas('pending', 'ret')) with pytest.raises(TransactionAlreadyPending): second_proxy.transact('ret', startgas) @@ -158,7 +158,7 @@ def test_duplicated_transaction_different_gas_price_raises(deploy_client): contract_proxy.contract_address, ) - startgas = safe_gas_limit(contract_proxy.estimate_gas('ret')) + startgas = safe_gas_limit(contract_proxy.estimate_gas('pending', 'ret')) with pytest.raises(ReplacementTransactionUnderpriced): second_proxy.transact('ret', startgas) @@ -172,7 +172,7 @@ def test_transact_opcode(deploy_client): address = contract_proxy.contract_address assert len(deploy_client.web3.eth.getCode(to_checksum_address(address))) > 0 - startgas = contract_proxy.estimate_gas('ret') * 2 + startgas = contract_proxy.estimate_gas('pending', 'ret') * 2 transaction = contract_proxy.transact('ret', startgas) deploy_client.poll(transaction) @@ -204,7 +204,7 @@ def test_transact_opcode_oog(deploy_client): assert len(deploy_client.web3.eth.getCode(to_checksum_address(address))) > 0 # divide the estimate by 2 to run into out-of-gas - startgas = safe_gas_limit(contract_proxy.estimate_gas('loop', 1000)) // 2 + startgas = safe_gas_limit(contract_proxy.estimate_gas('pending', 'loop', 1000)) // 2 transaction = contract_proxy.transact('loop', startgas, 1000) deploy_client.poll(transaction) @@ -217,7 +217,7 @@ def test_filter_start_block_inclusive(deploy_client): contract_proxy = deploy_rpc_test_contract(deploy_client) # call the create event function twice and wait for confirmation each time - startgas = safe_gas_limit(contract_proxy.estimate_gas('createEvent', 1)) + startgas = safe_gas_limit(contract_proxy.estimate_gas('pending', 'createEvent', 1)) transaction_1 = contract_proxy.transact('createEvent', startgas, 1) deploy_client.poll(transaction_1) transaction_2 = contract_proxy.transact('createEvent', startgas, 2) @@ -249,7 +249,7 @@ def test_filter_end_block_inclusive(deploy_client): contract_proxy = deploy_rpc_test_contract(deploy_client) # call the create event function twice and wait for confirmation each time - startgas = safe_gas_limit(contract_proxy.estimate_gas('createEvent', 1)) + startgas = safe_gas_limit(contract_proxy.estimate_gas('pending', 'createEvent', 1)) transaction_1 = contract_proxy.transact('createEvent', startgas, 1) deploy_client.poll(transaction_1) transaction_2 = contract_proxy.transact('createEvent', startgas, 2) diff --git a/raiden/tests/integration/test_pythonapi.py b/raiden/tests/integration/test_pythonapi.py index 0e1d793f12..13094b1b76 100644 --- a/raiden/tests/integration/test_pythonapi.py +++ b/raiden/tests/integration/test_pythonapi.py @@ -111,7 +111,7 @@ def test_register_token_insufficient_eth(raiden_network, token_amount, contract_ # app1.raiden loses all its ETH because it has been naughty burn_eth(app1.raiden) - # At this point we should get the InsufficientFunds exception + # At this point we should get an UnrecoverableError due to InsufficientFunds with pytest.raises(InsufficientFunds): api1.token_network_register(registry_address, token_address) diff --git a/raiden/utils/__init__.py b/raiden/utils/__init__.py index 4d92772675..fa516b0838 100644 --- a/raiden/utils/__init__.py +++ b/raiden/utils/__init__.py @@ -280,11 +280,10 @@ def optional_address_to_string( return to_checksum_address(address) -def safe_gas_limit(*estimates) -> int: - """ Calculates a safe gas limit for a number of estimates. - - Even though it's not documented, it does happen that estimate_gas returns `None`. - This function takes care of this and adds a security margin as well. +def safe_gas_limit(*estimates: int) -> int: + """ Calculates a safe gas limit for a number of gas estimates + including a security margin """ - calculated_limit = max(gas or 0 for gas in estimates) + assert None not in estimates, 'if estimateGas returned None it should not reach here' + calculated_limit = max(estimates) return int(calculated_limit * constants.GAS_FACTOR)