diff --git a/pantos/client/library/api.py b/pantos/client/library/api.py index 47b6b6f..31c61eb 100644 --- a/pantos/client/library/api.py +++ b/pantos/client/library/api.py @@ -4,8 +4,9 @@ """ __all__ = [ 'Blockchain', 'BlockchainAddress', 'PantosClientError', 'PrivateKey', - 'ServiceNodeBid', 'TokenSymbol', 'decrypt_private_key', - 'retrieve_service_node_bids', 'retrieve_token_balance', 'transfer_tokens', + 'ServiceNodeBid', 'TokenSymbol', 'TokenTransferInfo', + 'decrypt_private_key', 'retrieve_service_node_bids', + 'retrieve_token_balance', 'transfer_tokens', 'deploy_pantos_compatible_token' ] @@ -33,10 +34,9 @@ TokenInteractor as _TokenInteractor from pantos.client.library.business.transfers import \ TransferInteractor as _TransferInteractor -from pantos.client.library.business.transfers import \ - TransferTokensResponse as _TransferTokensResponse from pantos.client.library.constants import \ TOKEN_SYMBOL_PAN as _TOKEN_SYMBOL_PAN +from pantos.client.library.entitites import TokenTransferInfo from pantos.client.library.exceptions import ClientError as _ClientError # Exception to be used by external client library users @@ -150,7 +150,7 @@ def transfer_tokens( sender_private_key: PrivateKey, recipient_address: BlockchainAddress, source_token_id: _TokenId, token_amount: _Amount, service_node_bid: _typing.Optional[_BlockchainAddressBidPair] = None) \ - -> _TransferTokensResponse: + -> TokenTransferInfo: """Transfer tokens from a sender's account on a source blockchain to a recipient's account on a (possibly different) destination blockchain. @@ -181,8 +181,8 @@ def transfer_tokens( Returns ------- - TransferTokensResponse - The response of the token transfer. + TokenTransferInfo + The information of the token transfer. Raises ------ diff --git a/pantos/client/library/business/transfers.py b/pantos/client/library/business/transfers.py index e203cd7..0d5e2bb 100644 --- a/pantos/client/library/business/transfers.py +++ b/pantos/client/library/business/transfers.py @@ -10,6 +10,7 @@ from pantos.common.blockchains.base import Blockchain from pantos.common.entities import BlockchainAddressBidPair from pantos.common.entities import ServiceNodeBid +from pantos.common.entities import ServiceNodeTransferStatus from pantos.common.servicenodes import ServiceNodeClient from pantos.common.types import Amount from pantos.common.types import BlockchainAddress @@ -18,10 +19,15 @@ from pantos.client.library.blockchains import BlockchainClient from pantos.client.library.blockchains import get_blockchain_client +from pantos.client.library.blockchains.base import UnknownTransferError from pantos.client.library.business.base import Interactor from pantos.client.library.business.base import InteractorError from pantos.client.library.business.bids import BidInteractor from pantos.client.library.business.tokens import TokenInteractor +from pantos.client.library.configuration import get_blockchain_config +from pantos.client.library.entitites import DestinationTransferStatus +from pantos.client.library.entitites import TokenTransferInfo +from pantos.client.library.entitites import TokenTransferStatus _DEFAULT_VALID_UNTIL_BUFFER = 120 """Default "valid until" timestamp buffer for a token transfer in seconds.""" @@ -34,28 +40,33 @@ class TransferInteractorError(InteractorError): pass -@dataclasses.dataclass -class TransferTokensResponse: - """Response data for a new token transfer. - - Attributes - ---------- - task_id : uuid.UUID - The task ID of the chosen service node for the token - transfer. - service_node_address : BlockchainAddress - The address of the chosen service node for the token - transfer. +class TransferInteractor(Interactor): + """Interactor for handling Pantos token transfers. """ - task_id: uuid.UUID - service_node_address: BlockchainAddress + @dataclasses.dataclass + class TokenTransferStatusRequest: + """Request data for the status of a token transfer. + Attributes + ---------- + source_blockchain : Blockchain + The token transfer's source blockchain. + service_node_address : BlockchainAddress + The address of the service node that is processing the + token transfer. + task_id : uuid.UUID + The task ID of the token transfer. + blocks_to_search : int or None + The number of blocks to search for the token transfer + (default: None). -class TransferInteractor(Interactor): - """Interactor for handling Pantos token transfers. + """ + source_blockchain: Blockchain + service_node_address: BlockchainAddress + task_id: uuid.UUID + blocks_to_search: int | None = None - """ @dataclasses.dataclass class TransferTokensRequest: """Request data for a new token transfer. @@ -98,8 +109,8 @@ class TransferTokensRequest: service_node_bid: typing.Optional[BlockchainAddressBidPair] = None valid_until_buffer: int = _DEFAULT_VALID_UNTIL_BUFFER - def transfer_tokens( - self, request: TransferTokensRequest) -> TransferTokensResponse: + def transfer_tokens(self, + request: TransferTokensRequest) -> TokenTransferInfo: """Transfer tokens from a sender's account on a source blockchain to a recipient's account on a (possibly different) destination blockchain. @@ -111,8 +122,8 @@ def transfer_tokens( Returns ------- - TransferTokensResponse - The response data of the token transfer. + TokenTransferInfo + The information of the token transfer. Raises ------ @@ -178,13 +189,75 @@ def transfer_tokens( signature) task_id = ServiceNodeClient().submit_transfer( submit_transfer_request) - return TransferTokensResponse(task_id, service_node_address) + return TokenTransferInfo(task_id, service_node_address) except TransferInteractorError: raise except Exception: raise TransferInteractorError('unable to execute a token transfer', request=request) + def get_token_transfer_status(self, request: TokenTransferStatusRequest) \ + -> TokenTransferStatus: + """Get the status of a token transfer. + + Parameters + ---------- + request : TokenTransferStatusRequest + The request data for the status of a token transfer. + + Returns + ------- + TokenTransferStatus + The data of the token transfer status. + + Raises + ------ + TransferInteractorError + If the token transfer status cannot be retrieved. + + """ + try: + service_node_url = get_blockchain_client( + request.source_blockchain).read_service_node_url( + request.service_node_address) + source_status = ServiceNodeClient().status(service_node_url, + request.task_id) + token_transfer_status = \ + self.__create_token_transfer_status_response(source_status) + if source_status.status is not ServiceNodeTransferStatus.CONFIRMED: + return token_transfer_status + source_transaction_id = source_status.transaction_id + token_transfer_status.source_transaction_id = source_transaction_id + token_transfer_status.source_transfer_id = \ + source_status.transfer_id + destination_transfer_request = \ + BlockchainClient.DestinationTransferRequest( + request.source_blockchain, source_transaction_id) + try: + destination_response = get_blockchain_client( + source_status.destination_blockchain + ).read_destination_transfer(destination_transfer_request) + except UnknownTransferError: + return token_transfer_status + token_transfer_status.destination_transfer_status = \ + self.__get_destination_transfer_status( + destination_response.latest_block_number, + destination_response.transaction_block_number, + source_status.destination_blockchain) + token_transfer_status.destination_transaction_id = \ + destination_response.destination_transaction_id + token_transfer_status.destination_transfer_id = \ + destination_response.destination_transfer_id + token_transfer_status.validator_nonce = \ + destination_response.validator_nonce + token_transfer_status.signer_addresses = \ + destination_response.signer_addresses + token_transfer_status.signatures = destination_response.signatures + return token_transfer_status + except Exception: + raise TransferInteractorError( + 'unable to get token transfer status', request=request) + def __compute_token_amount(self, request: TransferTokensRequest, source_token_address: BlockchainAddress) -> int: if isinstance(request.token_amount, int): @@ -225,3 +298,25 @@ def __validate_recipient_address(self, request: TransferTokensRequest): recipient_address): raise TransferInteractorError('invalid recipient address', recipient_address=recipient_address) + + def __create_token_transfer_status_response( + self, + transfer_status: ServiceNodeClient.TransferStatusResponse) \ + -> TokenTransferStatus: + return TokenTransferStatus( + destination_blockchain=transfer_status.destination_blockchain, + source_transfer_status=transfer_status.status, + destination_transfer_status=DestinationTransferStatus.UNKNOWN, + sender_address=transfer_status.sender_address, + recipient_address=transfer_status.recipient_address, + source_token_address=transfer_status.source_token_address, + destination_token_address=transfer_status. + destination_token_address, amount=transfer_status.token_amount) + + def __get_destination_transfer_status( + self, latest_block_number: int, transaction_block_number: int, + blockchain: Blockchain) -> DestinationTransferStatus: + confirmations = get_blockchain_config(blockchain)['confirmations'] + if latest_block_number - transaction_block_number < confirmations: + return DestinationTransferStatus.SUBMITTED + return DestinationTransferStatus.CONFIRMED diff --git a/pantos/client/library/entitites.py b/pantos/client/library/entitites.py new file mode 100644 index 0000000..cb5b130 --- /dev/null +++ b/pantos/client/library/entitites.py @@ -0,0 +1,101 @@ +"""Module that defines the Pantos client library entities. + +""" +import dataclasses +import enum +import uuid + +from pantos.common.blockchains.enums import Blockchain +from pantos.common.servicenodes import ServiceNodeTransferStatus +from pantos.common.types import BlockchainAddress + + +class DestinationTransferStatus(enum.Enum): + """Enumeration of the possible destination transfer statuses. + + """ + UNKNOWN = 0 + SUBMITTED = 1 + CONFIRMED = 2 + + +@dataclasses.dataclass +class TokenTransferInfo: + """Information about a token transfer. + + Attributes + ---------- + task_id : uuid.UUID + The task ID of the chosen service node for the token + transfer. + service_node_address : BlockchainAddress + The address of the chosen service node for the token + transfer. + + """ + task_id: uuid.UUID + service_node_address: BlockchainAddress + + +@dataclasses.dataclass +class TokenTransferStatus: + """Data for the status of a token transfer. + + Attributes + ---------- + destination_blockchain : Blockchain + The token transfer's destination blockchain. + source_transfer_status : ServiceNodeTransferStatus + The status of the token transfer on the source blockchain. + destination_transfer_status : DestinationTransferStatus + The status of the token transfer on the destination blockchain. + source_transaction_id : str or None + The transaction ID of the token transfer on the source + blockchain (default: None). + destination_transaction_id : str or None + The transaction ID of the token transfer on the destination + blockchain (default: None). + source_transfer_id : int or None + The transfer ID of the token transfer on the source + blockchain (default: None). + destination_transfer_id : int or None + The transfer ID of the token transfer on the destination + blockchain (default: None). + sender_address : BlockchainAddress or None + The address of the sender's account on the source blockchain + (default: None). + recipient_address : BlockchainAddress or None + The address of the recipient's account on the destination + blockchain (default: None). + source_token_address : BlockchainAddress or None + The address of the token on the source blockchain (default: + None). + destination_token_address : BlockchainAddress or None + The address of the token on the destination blockchain + (default: None). + amount : int or None + The amount of tokens transferred (default: None). + validator_nonce : int or None + The validator nonce of the token transfer (default: None). + signer_addresses : list[BlockchainAddress] or None + The addresses of the signers of the token transfer (default: + None). + signatures : list[str] or None + The signatures of the token transfer (default: None). + + """ + destination_blockchain: Blockchain + source_transfer_status: ServiceNodeTransferStatus + destination_transfer_status: DestinationTransferStatus + source_transaction_id: str | None = None + destination_transaction_id: str | None = None + source_transfer_id: int | None = None + destination_transfer_id: int | None = None + sender_address: BlockchainAddress | None = None + recipient_address: BlockchainAddress | None = None + source_token_address: BlockchainAddress | None = None + destination_token_address: BlockchainAddress | None = None + amount: int | None = None + validator_nonce: int | None = None + signer_addresses: list[BlockchainAddress] | None = None + signatures: list[str] | None = None diff --git a/tests/business/test_transfers.py b/tests/business/test_transfers.py index e7c0330..1713bd0 100644 --- a/tests/business/test_transfers.py +++ b/tests/business/test_transfers.py @@ -1,13 +1,19 @@ +import itertools import unittest.mock import pytest from pantos.common.blockchains.base import Blockchain +from pantos.common.servicenodes import ServiceNodeTransferStatus from pantos.common.types import PrivateKey from pantos.client import BlockchainAddress +from pantos.client.library.blockchains.base import UnknownTransferError from pantos.client.library.business.tokens import TokenInteractorError +from pantos.client.library.business.transfers import ServiceNodeClient from pantos.client.library.business.transfers import TransferInteractor from pantos.client.library.business.transfers import TransferInteractorError +from pantos.client.library.entitites import DestinationTransferStatus +from pantos.client.library.entitites import TokenTransferStatus @unittest.mock.patch( @@ -24,3 +30,178 @@ def test_transfer_tokens(mocked_find_token_addresses): with pytest.raises(TransferInteractorError): TransferInteractor().transfer_tokens(transfer_token_request) + + +@pytest.mark.parametrize('service_node_status', + [[source_blockchain, destination_blockchain] + for source_blockchain, destination_blockchain in + itertools.product(Blockchain, repeat=2)], + indirect=True) +@unittest.mock.patch.object(ServiceNodeClient, 'status') +@unittest.mock.patch('pantos.client.library.business.transfers.' + 'get_blockchain_client') +def test_get_token_transfer_status_unconfirmed_source_correct( + mocked_blockchain_client, mocked_sn_status, service_node_status, + service_node_url, service_node_1, task_uuid): + mocked_blockchain_client().read_service_node_url.return_value = \ + service_node_url + mocked_sn_status.return_value = service_node_status + expected_response = TokenTransferStatus( + destination_blockchain=service_node_status.destination_blockchain, + source_transfer_status=service_node_status.status, + destination_transfer_status=DestinationTransferStatus.UNKNOWN, + sender_address=service_node_status.sender_address, + recipient_address=service_node_status.recipient_address, + source_token_address=service_node_status.source_token_address, + destination_token_address=service_node_status. + destination_token_address, amount=service_node_status.token_amount) + request = TransferInteractor.TokenTransferStatusRequest( + service_node_status.source_blockchain, service_node_1, task_uuid) + + response = TransferInteractor().get_token_transfer_status(request) + + assert expected_response == response + + +@pytest.mark.parametrize('service_node_status', + [[source_blockchain, destination_blockchain] + for source_blockchain, destination_blockchain in + itertools.product(Blockchain, repeat=2)], + indirect=True) +@unittest.mock.patch.object(ServiceNodeClient, 'status') +@unittest.mock.patch('pantos.client.library.business.transfers.' + 'get_blockchain_client') +def test_get_token_transfer_status_confirmed_source_unknown_destination( + mocked_blockchain_client, mocked_sn_status, service_node_status, + service_node_url, service_node_1, task_uuid): + mocked_blockchain_client().read_service_node_url.return_value = \ + service_node_url + mocked_blockchain_client().read_destination_transfer.side_effect = \ + UnknownTransferError() + service_node_status.status = ServiceNodeTransferStatus.CONFIRMED + mocked_sn_status.return_value = service_node_status + expected_response = TokenTransferStatus( + destination_blockchain=service_node_status.destination_blockchain, + source_transfer_status=service_node_status.status, + destination_transfer_status=DestinationTransferStatus.UNKNOWN, + source_transaction_id=service_node_status.transaction_id, + source_transfer_id=service_node_status.transfer_id, + sender_address=service_node_status.sender_address, + recipient_address=service_node_status.recipient_address, + source_token_address=service_node_status.source_token_address, + destination_token_address=service_node_status. + destination_token_address, amount=service_node_status.token_amount) + request = TransferInteractor.TokenTransferStatusRequest( + service_node_status.source_blockchain, service_node_1, task_uuid) + + response = TransferInteractor().get_token_transfer_status(request) + + assert expected_response == response + + +@pytest.mark.parametrize('service_node_status', + [[source_blockchain, destination_blockchain] + for source_blockchain, destination_blockchain in + itertools.product(Blockchain, repeat=2)], + indirect=True) +@unittest.mock.patch.object(ServiceNodeClient, 'status') +@unittest.mock.patch('pantos.client.library.business.transfers.' + 'get_blockchain_config') +@unittest.mock.patch('pantos.client.library.business.transfers.' + 'get_blockchain_client') +def test_get_token_transfer_status_confirmed_source_confirmed_destination( + mocked_blockchain_client, mocked_blockchain_config, mocked_sn_status, + service_node_status, destination_transfer_response, service_node_url, + service_node_1, task_uuid): + mocked_blockchain_client().read_service_node_url.return_value = \ + service_node_url + mocked_blockchain_client().read_destination_transfer.return_value = \ + destination_transfer_response + mocked_blockchain_config().__getitem__.return_value = ( + destination_transfer_response.latest_block_number - + destination_transfer_response.transaction_block_number - 1) + service_node_status.status = ServiceNodeTransferStatus.CONFIRMED + mocked_sn_status.return_value = service_node_status + expected_response = TokenTransferStatus( + destination_blockchain=service_node_status.destination_blockchain, + source_transfer_status=service_node_status.status, + destination_transfer_status=DestinationTransferStatus.CONFIRMED, + source_transaction_id=service_node_status.transaction_id, + destination_transaction_id=destination_transfer_response. + destination_transaction_id, + source_transfer_id=service_node_status.transfer_id, + destination_transfer_id=destination_transfer_response. + destination_transfer_id, + sender_address=service_node_status.sender_address, + recipient_address=service_node_status.recipient_address, + source_token_address=service_node_status.source_token_address, + destination_token_address=service_node_status. + destination_token_address, amount=service_node_status.token_amount, + validator_nonce=destination_transfer_response.validator_nonce, + signer_addresses=destination_transfer_response.signer_addresses, + signatures=destination_transfer_response.signatures) + request = TransferInteractor.TokenTransferStatusRequest( + service_node_status.source_blockchain, service_node_1, task_uuid) + + response = TransferInteractor().get_token_transfer_status(request) + + assert expected_response == response + + +@pytest.mark.parametrize('service_node_status', + [[source_blockchain, destination_blockchain] + for source_blockchain, destination_blockchain in + itertools.product(Blockchain, repeat=2)], + indirect=True) +@unittest.mock.patch.object(ServiceNodeClient, 'status') +@unittest.mock.patch('pantos.client.library.business.transfers.' + 'get_blockchain_config') +@unittest.mock.patch('pantos.client.library.business.transfers.' + 'get_blockchain_client') +def test_get_token_transfer_status_confirmed_source_submitted_destination( + mocked_blockchain_client, mocked_blockchain_config, mocked_sn_status, + service_node_status, destination_transfer_response, service_node_url, + service_node_1, task_uuid): + mocked_blockchain_client().read_service_node_url.return_value = \ + service_node_url + mocked_blockchain_client().read_destination_transfer.return_value = \ + destination_transfer_response + mocked_blockchain_config().__getitem__.return_value = ( + destination_transfer_response.latest_block_number - + destination_transfer_response.transaction_block_number + 1) + service_node_status.status = ServiceNodeTransferStatus.CONFIRMED + mocked_sn_status.return_value = service_node_status + expected_response = TokenTransferStatus( + destination_blockchain=service_node_status.destination_blockchain, + source_transfer_status=service_node_status.status, + destination_transfer_status=DestinationTransferStatus.SUBMITTED, + source_transaction_id=service_node_status.transaction_id, + destination_transaction_id=destination_transfer_response. + destination_transaction_id, + source_transfer_id=service_node_status.transfer_id, + destination_transfer_id=destination_transfer_response. + destination_transfer_id, + sender_address=service_node_status.sender_address, + recipient_address=service_node_status.recipient_address, + source_token_address=service_node_status.source_token_address, + destination_token_address=service_node_status. + destination_token_address, amount=service_node_status.token_amount, + validator_nonce=destination_transfer_response.validator_nonce, + signer_addresses=destination_transfer_response.signer_addresses, + signatures=destination_transfer_response.signatures) + request = TransferInteractor.TokenTransferStatusRequest( + service_node_status.source_blockchain, service_node_1, task_uuid) + + response = TransferInteractor().get_token_transfer_status(request) + + assert expected_response == response + + +@unittest.mock.patch( + 'pantos.client.library.business.transfers.' + 'get_blockchain_client', side_effet=Exception) +def test_get_token_transfer_status_error(service_node_1, task_uuid): + request = TransferInteractor.TokenTransferStatusRequest( + Blockchain.ETHEREUM, service_node_1, task_uuid) + with pytest.raises(TransferInteractorError): + TransferInteractor().get_token_transfer_status(request) diff --git a/tests/conftest.py b/tests/conftest.py index 6248584..4f9ab54 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,11 +1,15 @@ """Shared fixtures for all pantos.client.library package tests. """ +import uuid + import hexbytes import pytest from pantos.common.blockchains.base import Blockchain from pantos.common.entities import BlockchainAddress from pantos.common.entities import ServiceNodeBid +from pantos.common.servicenodes import ServiceNodeClient +from pantos.common.servicenodes import ServiceNodeTransferStatus from pantos.common.types import PrivateKey from pantos.client.library.blockchains.base import BlockchainClient @@ -33,6 +37,8 @@ _NONCE = 11111 +_TASK_UUID = uuid.UUID('b6b59888-41c2-4555-825f-47ce387d6853') + _SIGNER_ADDRESSES = [ BlockchainAddress('0xBb608811Bfc5fc3444863BC589C7e5F50DF1936a') ] @@ -43,6 +49,8 @@ 'aaa116b69b2e0927a3151de498f5f0131beafbadb6c12c1756baa532d931fa81c') ] +_SERVICE_NODE_URL = 'http://localhost:8080' + _SERVICE_NODE_1 = BlockchainAddress( '0x5188287E724140aa3C432dCfE69E00992aF09d09') @@ -70,6 +78,11 @@ ] +@pytest.fixture(scope='module') +def service_node_url(): + return _SERVICE_NODE_URL + + @pytest.fixture(scope='module') def service_node_1(): return _SERVICE_NODE_1 @@ -95,6 +108,11 @@ def token_address(): return _TOKEN_ADDRESS +@pytest.fixture(scope='module') +def task_uuid(scope='module'): + return _TASK_UUID + + @pytest.fixture(scope='module') def config(scope='module'): return { @@ -211,3 +229,21 @@ def transfer_to_event(request): 'signatures': _SIGNATURES } } + + +@pytest.fixture(scope='function') +def service_node_status(request): + return ServiceNodeClient.TransferStatusResponse( + _TASK_UUID, request.param[0], request.param[1], _SENDER, _RECIPIENT, + _SOURCE_TOKEN, _DESTINATION_TOKEN, _AMOUNT, _AMOUNT, + ServiceNodeTransferStatus.ACCEPTED, _SOURCE_TRANSFER_ID, + _TRANSACTION_HASH) + + +@pytest.fixture(scope='function') +def destination_transfer_response(): + return BlockchainClient.DestinationTransferResponse( + _BLOCK_NUMBER + 100, _BLOCK_NUMBER, _TRANSACTION_HASH, + _SOURCE_TRANSFER_ID, _DESTINATION_TRANSFER_ID, _SENDER, _RECIPIENT, + _SOURCE_TOKEN, _DESTINATION_TOKEN, _AMOUNT, _NONCE, _SIGNER_ADDRESSES, + _SIGNATURES)