diff --git a/pantos/servicenode/blockchains/base.py b/pantos/servicenode/blockchains/base.py index e7510a2..cd954a5 100644 --- a/pantos/servicenode/blockchains/base.py +++ b/pantos/servicenode/blockchains/base.py @@ -183,6 +183,61 @@ def is_valid_recipient_address(self, recipient_address: str) -> bool: """ pass # pragma: no cover + @dataclasses.dataclass + class ExternalTokenRecordRequest: + """Request data for reading an external token record. + + Attributes + ---------- + token_address : BlockchainAddress + The address of the token on the blockchain the external + token record is read from. + external_blockchain : Blockchain + The blockchain to read the external token record for. + + """ + token_address: BlockchainAddress + external_blockchain: Blockchain + + @dataclasses.dataclass + class ExternalTokenRecordResponse: + """Response data from reading an external token record. + + Attributes + ---------- + is_registration_active : bool + True if the external token registration is active. + external_token_address : BlockchainAddress + The registered external address of the token. + + """ + is_registration_active: bool + external_token_address: BlockchainAddress + + @abc.abstractmethod + def read_external_token_record( + self, request: ExternalTokenRecordRequest) \ + -> ExternalTokenRecordResponse: + """Read an external token record from the Pantos Hub. + + Parameters + ---------- + request : ExternalTokenRecordRequest + The request data. + + Returns + ------- + ExternalTokenRecordResponse + The response data. + + Raises + ------ + BlockchainClientError + If the external token record cannot be read. + + """ + pass # pragma: no cover + @abc.abstractmethod def read_minimum_deposit(self) -> int: """Read the service node's minimum deposit at the Pantos Hub on diff --git a/pantos/servicenode/blockchains/ethereum.py b/pantos/servicenode/blockchains/ethereum.py index 41bb6ef..d3fe242 100644 --- a/pantos/servicenode/blockchains/ethereum.py +++ b/pantos/servicenode/blockchains/ethereum.py @@ -105,6 +105,29 @@ def is_valid_recipient_address(self, recipient_address: str) -> bool: is_zero_address = int(recipient_address, 0) == 0 return not is_zero_address + def read_external_token_record( + self, request: BlockchainClient.ExternalTokenRecordRequest) \ + -> BlockchainClient.ExternalTokenRecordResponse: + # Docstring inherited + try: + node_connections = self.__create_node_connections() + hub_contract = self._create_hub_contract(node_connections) + external_token_record = hub_contract.caller().\ + getExternalTokenRecord(request.token_address, + request.external_blockchain.value).get() + assert len(external_token_record) == 2 + is_registration_active = external_token_record[0] + assert isinstance(is_registration_active, bool) + external_token_address = external_token_record[1] + assert isinstance(external_token_address, str) + return BlockchainClient.ExternalTokenRecordResponse( + is_registration_active=is_registration_active, + external_token_address=BlockchainAddress( + external_token_address)) + except Exception: + raise self._create_error('unable to read an external token record', + request=request) + def read_minimum_deposit(self) -> int: # Docstring inherited try: diff --git a/pantos/servicenode/blockchains/solana.py b/pantos/servicenode/blockchains/solana.py index 261024b..9e08288 100644 --- a/pantos/servicenode/blockchains/solana.py +++ b/pantos/servicenode/blockchains/solana.py @@ -47,6 +47,12 @@ def is_valid_recipient_address(self, recipient_address: str) -> bool: # Docstring inherited raise NotImplementedError # pragma: no cover + def read_external_token_record( + self, request: BlockchainClient.ExternalTokenRecordRequest) \ + -> BlockchainClient.ExternalTokenRecordResponse: + # Docstring inherited + raise NotImplementedError # pragma: no cover + def read_minimum_deposit(self) -> int: # Docstring inherited raise NotImplementedError # pragma: no cover diff --git a/pantos/servicenode/business/transfers.py b/pantos/servicenode/business/transfers.py index 1a6be21..72f32d5 100644 --- a/pantos/servicenode/business/transfers.py +++ b/pantos/servicenode/business/transfers.py @@ -290,6 +290,9 @@ def __single_chain_transfer(self, def __cross_chain_transfer(self, request: ExecuteTransferRequest) -> uuid.UUID: + source_blockchain_client = get_blockchain_client( + request.source_blockchain) + self.__validate_destination_token(source_blockchain_client, request) transfer_from_request = \ BlockchainClient.TransferFromSubmissionStartRequest( request.internal_transfer_id, @@ -299,8 +302,6 @@ def __cross_chain_transfer(self, request.destination_token_address, request.amount, request.fee, request.sender_nonce, request.valid_until, request.signature) - source_blockchain_client = get_blockchain_client( - request.source_blockchain) try: return source_blockchain_client.start_transfer_from_submission( transfer_from_request) @@ -316,6 +317,23 @@ def __cross_chain_transfer(self, request.internal_transfer_id, TransferStatus.ACCEPTED) raise + def __validate_destination_token( + self, source_blockchain_client: BlockchainClient, + request: ExecuteTransferRequest) -> None: + external_token_request = BlockchainClient.ExternalTokenRecordRequest( + token_address=request.source_token_address, + external_blockchain=request.destination_blockchain) + external_token_response = source_blockchain_client.\ + read_external_token_record(external_token_request) + if (not external_token_response.is_registration_active + or request.destination_token_address + != external_token_response.external_token_address): + database_access.update_transfer_status( + request.internal_transfer_id, TransferStatus.FAILED) + raise TransferInteractorUnrecoverableError( + 'invalid destination token', request=request, + **dataclasses.asdict(external_token_response)) + def __check_valid_until(self, source_blockchain: Blockchain, valid_until: int, bid_execution_time: int, time_received: float) -> None: diff --git a/tests/blockchains/test_ethereum.py b/tests/blockchains/test_ethereum.py index 0dbb555..5526631 100644 --- a/tests/blockchains/test_ethereum.py +++ b/tests/blockchains/test_ethereum.py @@ -69,6 +69,10 @@ _TRANSFER_DESTIONATION_TOKEN_ADDRESS = 'destination_token' +_TOKEN_ADDRESS = '0xa6fd6EB118BBdf6c4B31866542972d3D589b24C6' + +_EXTERNAL_TOKEN_ADDRESS = '0x4E4d4470d72CA0CE478d6f87a1ae3a868F0e8Bb9' + @pytest.fixture(scope='module') def web3_account(): @@ -333,6 +337,49 @@ def test_is_valid_recipient_address_0_address_false(mock_is_valid_address, assert is_recipient_address_correct is False +@pytest.mark.parametrize('is_registration_active', [True, False]) +@pytest.mark.parametrize('external_blockchain', [ + blockchain + for blockchain in Blockchain if blockchain is not Blockchain.ETHEREUM +]) +def test_read_external_token_record_correct( + external_blockchain, is_registration_active, ethereum_client, + mock_get_blockchain_config, provider_timeout, hub_contract_address, + mock_get_blockchain_utilities): + mock_get_blockchain_config.return_value = { + 'provider_timeout': provider_timeout, + 'hub': hub_contract_address + } + mock_get_blockchain_utilities().create_contract().caller().\ + getExternalTokenRecord().get.return_value = (is_registration_active, + _EXTERNAL_TOKEN_ADDRESS) + + request = BlockchainClient.ExternalTokenRecordRequest( + token_address=_TOKEN_ADDRESS, external_blockchain=external_blockchain) + response = ethereum_client.read_external_token_record(request) + + assert response.is_registration_active is is_registration_active + assert response.external_token_address == _EXTERNAL_TOKEN_ADDRESS + + +def test_read_external_token_record_error(ethereum_client, + mock_get_blockchain_config, + provider_timeout, + hub_contract_address, + mock_get_blockchain_utilities): + mock_get_blockchain_config.return_value = { + 'provider_timeout': provider_timeout, + 'hub': hub_contract_address + } + mock_get_blockchain_utilities().create_contract().caller.side_effect = \ + EthereumUtilitiesError + + request = BlockchainClient.ExternalTokenRecordRequest( + token_address=_TOKEN_ADDRESS, external_blockchain=Blockchain.AVALANCHE) + with pytest.raises(EthereumClientError): + ethereum_client.read_external_token_record(request) + + def test_read_minimum_deposit_correct(ethereum_client, mock_get_blockchain_config, provider_timeout, hub_contract_address, diff --git a/tests/business/test_transfers.py b/tests/business/test_transfers.py index 8e332a7..62b8d6a 100644 --- a/tests/business/test_transfers.py +++ b/tests/business/test_transfers.py @@ -319,6 +319,11 @@ def test_execute_transfer_cross_chain_correct(mocked_database_access, mocked_time, execute_transfer_request): mocked_time.time.return_value = execute_transfer_request.valid_until - 1 + mocked_get_blockchain_client().read_external_token_record.return_value = \ + BlockchainClient.ExternalTokenRecordResponse( + is_registration_active=True, + external_token_address=execute_transfer_request. + destination_token_address) expected_transfer_from_request = \ BlockchainClient.TransferFromSubmissionStartRequest( execute_transfer_request.internal_transfer_id, @@ -344,6 +349,50 @@ def test_execute_transfer_cross_chain_correct(mocked_database_access, start_transfer_from_submission(expected_transfer_from_request)) +@unittest.mock.patch('pantos.servicenode.business.transfers.time') +@unittest.mock.patch( + 'pantos.servicenode.business.transfers.get_blockchain_client') +@unittest.mock.patch('pantos.servicenode.business.transfers.database_access') +def test_execute_transfer_cross_chain_destination_token_inactive_error( + mocked_database_access, mocked_get_blockchain_client, mocked_time, + execute_transfer_request): + mocked_time.time.return_value = execute_transfer_request.valid_until - 1 + mocked_get_blockchain_client().read_external_token_record.return_value = \ + BlockchainClient.ExternalTokenRecordResponse( + is_registration_active=False, + external_token_address=execute_transfer_request. + destination_token_address) + + with pytest.raises(TransferInteractorUnrecoverableError): + TransferInteractor().execute_transfer(execute_transfer_request) + + mocked_database_access.update_transfer_status.assert_called_once_with( + execute_transfer_request.internal_transfer_id, TransferStatus.FAILED) + + +@unittest.mock.patch('pantos.servicenode.business.transfers.time') +@unittest.mock.patch( + 'pantos.servicenode.business.transfers.get_blockchain_client') +@unittest.mock.patch('pantos.servicenode.business.transfers.database_access') +def test_execute_transfer_cross_chain_destination_token_address_invalid_error( + mocked_database_access, mocked_get_blockchain_client, mocked_time, + execute_transfer_request): + external_token_address = '0x3b25C4449EF3aB8c383774a2194C573D1cC6e4c5' + assert (external_token_address + != execute_transfer_request.destination_token_address) + mocked_time.time.return_value = execute_transfer_request.valid_until - 1 + mocked_get_blockchain_client().read_external_token_record.return_value = \ + BlockchainClient.ExternalTokenRecordResponse( + is_registration_active=True, + external_token_address=external_token_address) + + with pytest.raises(TransferInteractorUnrecoverableError): + TransferInteractor().execute_transfer(execute_transfer_request) + + mocked_database_access.update_transfer_status.assert_called_once_with( + execute_transfer_request.internal_transfer_id, TransferStatus.FAILED) + + @unittest.mock.patch('pantos.servicenode.business.transfers.' 'time') @unittest.mock.patch('pantos.servicenode.business.transfers.' @@ -354,6 +403,11 @@ def test_execute_transfer_cross_chain_unrecoverable_error( mocked_database_access, mocked_get_blockchain_client, mocked_time, execute_transfer_request): mocked_time.time.return_value = execute_transfer_request.valid_until - 1 + mocked_get_blockchain_client().read_external_token_record.return_value = \ + BlockchainClient.ExternalTokenRecordResponse( + is_registration_active=True, + external_token_address=execute_transfer_request. + destination_token_address) mocked_get_blockchain_client().start_transfer_from_submission.\ side_effect = InvalidSignatureError