diff --git a/client-library.yml b/client-library.yml index d3f19b2..8a83b30 100644 --- a/client-library.yml +++ b/client-library.yml @@ -11,6 +11,7 @@ blockchains: fallback_providers: - !ENV ${AVALANCHE_FALLBACK_PROVIDER:https://rpc.ankr.com/avalanche_fuji} average_block_time: !ENV tag:yaml.org,2002:int ${AVALANCHE_AVERAGE_BLOCK_TIME:3} + blocks_per_query: !ENV tag:yaml.org,2002:int ${AVALANCHE_BLOCKS_PER_QUERY:2000} chain_id: !ENV tag:yaml.org,2002:int ${AVALANCHE_CHAIN_ID:43113} confirmations: !ENV tag:yaml.org,2002:int ${AVALANCHE_CONFIRMATIONS:20} hub: !ENV ${AVALANCHE_HUB:0xbafFb84601BeC1FCb4B842f8917E3eA850781BE7} @@ -32,6 +33,7 @@ blockchains: fallback_providers: - !ENV ${BNB_FALLBACK_PROVIDER:https://data-seed-prebsc-1-s1.binance.org:8545/} average_block_time: !ENV tag:yaml.org,2002:int ${BNB_AVERAGE_BLOCK_TIME:3} + blocks_per_query: !ENV tag:yaml.org,2002:int ${BNB_BLOCKS_PER_QUERY:2000} chain_id: !ENV tag:yaml.org,2002:int ${BNB_CHAIN_ID:97} confirmations: !ENV tag:yaml.org,2002:int ${BNB_CONFIRMATIONS:3} hub: !ENV ${BNB_HUB:0xFB37499DC5401Dc39a0734df1fC7924d769721d5} @@ -53,6 +55,7 @@ blockchains: fallback_providers: - !ENV ${CELO_FALLBACK_PROVIDER:https://alfajores-forno.celo-testnet.org} average_block_time: !ENV tag:yaml.org,2002:int ${CELO_AVERAGE_BLOCK_TIME:5} + blocks_per_query: !ENV tag:yaml.org,2002:int ${CELO_BLOCKS_PER_QUERY:2000} chain_id: !ENV tag:yaml.org,2002:int ${CELO_CHAIN_ID:44787} confirmations: !ENV tag:yaml.org,2002:int ${CELO_CONFIRMATIONS:3} hub: !ENV ${CELO_HUB:0x8389B9A7608dbf52a699b998f309883257923C0E} @@ -74,6 +77,7 @@ blockchains: fallback_providers: - !ENV ${CRONOS_FALLBACK_PROVIDER:https://evm-t3.cronos.org} average_block_time: !ENV tag:yaml.org,2002:int ${CRONOS_AVERAGE_BLOCK_TIME:5} + blocks_per_query: !ENV tag:yaml.org,2002:int ${CRONOS_BLOCKS_PER_QUERY:2000} chain_id: !ENV tag:yaml.org,2002:int ${CRONOS_CHAIN_ID:338} confirmations: !ENV tag:yaml.org,2002:int ${CRONOS_CONFIRMATIONS:3} hub: !ENV ${CRONOS_HUB:0x0Cfb3c7C11A33BEf124A9D86073e73932b9AbF90} @@ -95,6 +99,7 @@ blockchains: fallback_providers: - !ENV ${ETHEREUM_FALLBACK_PROVIDER:https://ethereum-holesky.publicnode.com} average_block_time: !ENV tag:yaml.org,2002:int ${ETHEREUM_AVERAGE_BLOCK_TIME:14} + blocks_per_query: !ENV tag:yaml.org,2002:int ${ETHEREUM_BLOCKS_PER_QUERY:2000} chain_id: !ENV tag:yaml.org,2002:int ${ETHEREUM_CHAIN_ID:17000} confirmations: !ENV tag:yaml.org,2002:int ${ETHEREUM_CONFIRMATIONS:20} hub: !ENV ${ETHEREUM_HUB:0x5e447968d4a177fE7bFB8877cA12aE20Bd60dD85} @@ -116,6 +121,7 @@ blockchains: fallback_providers: - !ENV ${FANTOM_FALLBACK_PROVIDER:https://rpc.ankr.com/fantom_testnet} average_block_time: !ENV tag:yaml.org,2002:int ${FANTOM_AVERAGE_BLOCK_TIME:1} + blocks_per_query: !ENV tag:yaml.org,2002:int ${FANTOM_BLOCKS_PER_QUERY:2000} chain_id: !ENV tag:yaml.org,2002:int ${FANTOM_CHAIN_ID:4002} confirmations: !ENV tag:yaml.org,2002:int ${FANTOM_CONFIRMATIONS:3} hub: !ENV ${FANTOM_HUB:0x4BC6A71D4C3D6170d0Db849fE19b8DbA18f1a7F5} @@ -137,6 +143,7 @@ blockchains: fallback_providers: - !ENV ${POLYGON_FALLBACK_PROVIDER:https://rpc.ankr.com/polygon_mumbai} average_block_time: !ENV tag:yaml.org,2002:int ${POLYGON_AVERAGE_BLOCK_TIME:3} + blocks_per_query: !ENV tag:yaml.org,2002:int ${POLYGON_BLOCKS_PER_QUERY:2000} chain_id: !ENV tag:yaml.org,2002:int ${POLYGON_CHAIN_ID:80001} confirmations: !ENV tag:yaml.org,2002:int ${POLYGON_CONFIRMATIONS:20} hub: !ENV ${POLYGON_HUB:0x5C4B92cd0A956dedc14AF31fD474931540D8277B} @@ -158,6 +165,7 @@ blockchains: fallback_providers: - !ENV ${SOLANA_FALLBACK_PROVIDER} average_block_time: !ENV tag:yaml.org,2002:int ${SOLANA_AVERAGE_BLOCK_TIME:1} + blocks_per_query: !ENV tag:yaml.org,2002:int ${SOLANA_BLOCKS_PER_QUERY:2000} chain_id: !ENV tag:yaml.org,2002:int ${SOLANA_CHAIN_ID:-1} confirmations: !ENV tag:yaml.org,2002:int ${SOLANA_CONFIRMATIONS:1} hub: !ENV ${SOLANA_HUB} diff --git a/pantos/client/library/blockchains/base.py b/pantos/client/library/blockchains/base.py index e90f237..2e848e4 100644 --- a/pantos/client/library/blockchains/base.py +++ b/pantos/client/library/blockchains/base.py @@ -38,6 +38,15 @@ class BlockchainClientError(ClientLibraryError): pass +class UnknownTransferError(BlockchainClientError): + """Exception raised for unknown token transfers. + + """ + def __init__(self, **kwargs: typing.Any): + # Docstring inherited + super().__init__('unknown transfer', **kwargs) + + class BlockchainClient(BlockchainHandler, ErrorCreator[BlockchainClientError]): """Base class for all blockchain clients. @@ -125,6 +134,82 @@ class ComputeTransferSignatureResponse: sender_nonce: int signature: str + @dataclasses.dataclass + class DestinationTransferRequest: + """Request data for a token transfer on the destination + blockchain. + + Attributes + ---------- + source_blockchain : Blockchain + The token transfer's source blockchain. + source_transaction_id : str + The transaction ID of the token transfer on the + source blockchain. + blocks_to_search : int | None + The blocks to search for the token transfer on the + destination blockchain. + + """ + source_blockchain: Blockchain + source_transaction_id: str + blocks_to_search: int | None = None + + @dataclasses.dataclass + class DestinationTransferResponse: + """Response data for a token transfer on the destination + blockchain. + + Attributes + ---------- + latest_block_number : int + The latest block number on the destination blockchain. + transaction_block_number : int + The block number of the token transfer transaction. + destination_transaction_id : str + The transaction ID of the token transfer. + source_transfer_id : int + The unique identifier of the token transfer on the source + blockchain. + destination_transfer_id : int + The unique identifier of the token transfer on the + destination blockchain. + sender_address : BlockchainAddress + The address of the sender's account. + recipient_address : BlockchainAddress + The address of the recipient's account. + source_token_address : BlockchainAddress + The transferred token's address on the source blockchain. + destination_token_address : BlockchainAddress + The transferred token's address on the destination + blockchain. + amount : int + The transferred token amount. + validator_nonce : int + The unique nonce of the validator for the token transfer on + the destination blockchain. + signer_addresses : list of BlockchainAddress + The addresses of the validators which signed the token + transfer on the destination blockchain. + signatures : list of str + The signatures of the validators which signed the token + transfer on the destination blockchain. + + """ + latest_block_number: int + transaction_block_number: int + destination_transaction_id: str + source_transfer_id: int + destination_transfer_id: int + sender_address: BlockchainAddress + recipient_address: BlockchainAddress + source_token_address: BlockchainAddress + destination_token_address: BlockchainAddress + amount: int + validator_nonce: int + signer_addresses: list[BlockchainAddress] + signatures: list[str] + @abc.abstractmethod def compute_transfer_signature( self, request: ComputeTransferSignatureRequest) \ @@ -289,9 +374,9 @@ def decrypt_private_key(self, keystore: str, password: str) -> PrivateKey: @abc.abstractmethod def read_external_token_address( - self, token_address: BlockchainAddress, - destination_blockchain: Blockchain - ) -> BlockchainAddress: # pragma: no cover + self, token_address: BlockchainAddress, + destination_blockchain: Blockchain) \ + -> BlockchainAddress: # pragma: no cover """Read an external token address that is registered at the Pantos Hub on the blockchain. @@ -338,8 +423,8 @@ def read_service_node_addresses( @abc.abstractmethod def read_service_node_url( - self, service_node_address: BlockchainAddress - ) -> str: # pragma: no cover + self, service_node_address: BlockchainAddress) \ + -> str: # pragma: no cover """Read a service node's URL that is registered at the Pantos Hub on the blockchain. @@ -362,6 +447,32 @@ def read_service_node_url( """ pass + @abc.abstractmethod + def read_destination_transfer( + self, request: DestinationTransferRequest) \ + -> DestinationTransferResponse: # pragma: no cover + """Read a token transfer on the destination blockchain. + + Parameters + ---------- + request : DestinationTransferRequest + The request data for reading the token transfer. + + Returns + ------- + DestinationTransferResponse + The response data with the token transfer information. + + Raises + ------ + UnknownTransferError + If the token transfer is unkown. + BlockchainClientError + If the token transfer cannot be read. + + """ + pass + def read_token_balance(self, token_address: BlockchainAddress, account_id: AccountId) -> int: """Read a blockchain account's balance of a Pantos-compatible @@ -418,6 +529,11 @@ def read_token_decimals( """ pass + def _create_unknown_transfer_error( + self, **kwargs: typing.Any) -> BlockchainClientError: + return self._create_error(specialized_error_class=UnknownTransferError, + **kwargs) + def _account_id_to_account_address( self, account_id: AccountId) -> BlockchainAddress: if isinstance(account_id, BlockchainAddress): diff --git a/pantos/client/library/blockchains/ethereum.py b/pantos/client/library/blockchains/ethereum.py index d11641d..17c6e2b 100644 --- a/pantos/client/library/blockchains/ethereum.py +++ b/pantos/client/library/blockchains/ethereum.py @@ -8,6 +8,7 @@ import hexbytes import web3 import web3.contract +import web3.types from pantos.common.blockchains.base import Blockchain from pantos.common.blockchains.base import NodeConnections from pantos.common.blockchains.enums import ContractAbi @@ -18,6 +19,7 @@ from pantos.client.library.blockchains.base import VERSIONED_CONTRACT_ABIS from pantos.client.library.blockchains.base import BlockchainClient from pantos.client.library.blockchains.base import BlockchainClientError +from pantos.client.library.blockchains.base import UnknownTransferError from pantos.client.library.constants import TOKEN_SYMBOL_PAN Web3Contract: typing.TypeAlias = NodeConnections.Wrapper[ @@ -192,6 +194,40 @@ def read_service_node_url(self, raise self._create_error('unable to read a service node URL', service_node_address=service_node_address) + def read_destination_transfer( + self, request: BlockchainClient.DestinationTransferRequest) \ + -> BlockchainClient.DestinationTransferResponse: + # Docstring inherited + try: + node_connections = self._get_utilities().create_node_connections() + to_block_number = \ + node_connections.eth.get_block_number().get_minimum_result() + from_block_number = (to_block_number - request.blocks_to_search + + 1 if request.blocks_to_search else 0) + blocks_per_query = self._get_config()['blocks_per_query'] + hub_contract = self._create_hub_contract(node_connections) + transfer_event = typing.cast( + NodeConnections.Wrapper[web3.contract.contract.ContractEvent], + hub_contract.events.TransferTo()) + for to_block_number_ in range(to_block_number + 1, + from_block_number, + -blocks_per_query): + from_block_number_ = max(to_block_number_ - blocks_per_query, + from_block_number) + transfer_event_logs = self._get_utilities().get_logs( + transfer_event, from_block_number_, to_block_number_ - 1) + transfer_response = self.__find_destination_transfer( + transfer_event_logs, request.source_transaction_id, + request.source_blockchain, to_block_number) + if transfer_response: + return transfer_response + raise self._create_unknown_transfer_error(request=request) + except UnknownTransferError: + raise + except Exception: + raise self._create_error('unable to read a destination transfer', + request=request) + def read_token_decimals(self, token_address: BlockchainAddress) -> int: # Docstring inherited try: @@ -246,3 +282,33 @@ def __sign_message(self, private_key: PrivateKey, signed_message = web3.Account.sign_message(message, private_key=private_key) return signed_message.signature.to_0x_hex() + + def __find_destination_transfer( + self, transfer_event_logs: list[web3.types.EventData], + source_transaction_id: str, source_blockchain_id: int, + to_block_number: int) \ + -> BlockchainClient.DestinationTransferResponse | None: + for transfer_event_log in transfer_event_logs: + transfer_event_args = transfer_event_log['args'] + if (transfer_event_args['sourceTransactionId'] + == source_transaction_id + and transfer_event_args['sourceBlockchainId'] + == source_blockchain_id): + return BlockchainClient.DestinationTransferResponse( + to_block_number, transfer_event_log['blockNumber'], + transfer_event_log['transactionHash'].hex(), + transfer_event_args['sourceTransferId'], + transfer_event_args['destinationTransferId'], + BlockchainAddress(transfer_event_args['sender']), + BlockchainAddress(transfer_event_args['recipient']), + BlockchainAddress(transfer_event_args['sourceToken']), + BlockchainAddress(transfer_event_args['destinationToken']), + transfer_event_args['amount'], + transfer_event_args['nonce'], [ + BlockchainAddress(signer_address) for signer_address in + transfer_event_args['signerAddresses'] + ], [ + signature.hex() + for signature in transfer_event_args['signatures'] + ]) + return None diff --git a/pantos/client/library/blockchains/solana.py b/pantos/client/library/blockchains/solana.py index e4b4213..0f51be8 100644 --- a/pantos/client/library/blockchains/solana.py +++ b/pantos/client/library/blockchains/solana.py @@ -75,6 +75,12 @@ def read_service_node_url(self, # Docstring inherited raise NotImplementedError + def read_destination_transfer( + self, request: BlockchainClient.DestinationTransferRequest) \ + -> BlockchainClient.DestinationTransferResponse: + # Docstring inherited + raise NotImplementedError + def read_token_decimals(self, token_address: BlockchainAddress) -> int: # Docstring inherited raise NotImplementedError diff --git a/pantos/client/library/configuration.py b/pantos/client/library/configuration.py index a3eb482..5812dd0 100644 --- a/pantos/client/library/configuration.py +++ b/pantos/client/library/configuration.py @@ -36,6 +36,11 @@ 'type': 'integer', 'required': True }, + 'blocks_per_query': { + 'type': 'integer', + 'required': True, + 'min': 1 + }, 'chain_id': { 'type': 'integer', 'required': True diff --git a/tests/blockchains/test_ethereum.py b/tests/blockchains/test_ethereum.py index f37104c..0df314b 100644 --- a/tests/blockchains/test_ethereum.py +++ b/tests/blockchains/test_ethereum.py @@ -5,6 +5,7 @@ from pantos.client.library.blockchains.ethereum import EthereumClient from pantos.client.library.blockchains.ethereum import EthereumClientError +from pantos.client.library.blockchains.ethereum import UnknownTransferError @pytest.fixture(scope='module') @@ -219,6 +220,112 @@ def test_read_service_nodes_correct(mocked_hub_contract, mocked_utilities, ] -def test_read_service_nodes_error(ethereum_client): - with pytest.raises(Exception): - ethereum_client.read_service_node_addresses() +@pytest.mark.parametrize('transfer_to_event', + [blockchain for blockchain in Blockchain], + indirect=True) +@unittest.mock.patch.object(EthereumClient, '_get_utilities') +@unittest.mock.patch.object(EthereumClient, '_get_config') +@unittest.mock.patch.object(EthereumClient, '_create_hub_contract') +def test_read_destination_transfer_correct( + mocked_hub_contract, mocked_get_config, mocked_get_utilities, + transfer_to_event, ethereum_client, source_transaction_id, + block_number, transaction_hash, source_transfer_id, + destination_transfer_id, sender, recipient, source_token, + destination_token, amount, nonce, signer_addresses, signatures): + mocked_get_utilities().create_node_connections().\ + eth.get_block_number().get_minimum_result.return_value = 1000 + mocked_get_config().__getitem__.return_value = 5 + expected_response = EthereumClient.DestinationTransferResponse( + 1000, block_number, transaction_hash.hex(), source_transfer_id, + destination_transfer_id, sender, recipient, source_token, + destination_token, amount, nonce, signer_addresses, + [signature.hex() for signature in signatures]) + mocked_get_utilities().get_logs.return_value = [transfer_to_event] + request = EthereumClient.DestinationTransferRequest( + transfer_to_event['args']['sourceBlockchainId'], source_transaction_id) + + transfer_response = ethereum_client.read_destination_transfer(request) + + assert expected_response == transfer_response + + +@pytest.mark.parametrize('blocks_queried_expected', [{ + 'blocks_to_search': 10, + 'last_block_number': 20, + 'blocks_per_query': 5, + 'expected': [(16, 20), (11, 15)] +}, { + 'blocks_to_search': 5, + 'last_block_number': 15, + 'blocks_per_query': 1, + 'expected': [(15, 15), (14, 14), (13, 13), (12, 12), (11, 11)] +}, { + 'blocks_to_search': 10, + 'last_block_number': 20, + 'blocks_per_query': 15, + 'expected': [(11, 20)] +}, { + 'blocks_to_search': 10, + 'last_block_number': 20, + 'blocks_per_query': 7, + 'expected': [(14, 20), (11, 13)] +}, { + 'blocks_to_search': 1, + 'last_block_number': 15, + 'blocks_per_query': 5, + 'expected': [(15, 15)] +}]) +@unittest.mock.patch.object(EthereumClient, '_get_utilities') +@unittest.mock.patch.object(EthereumClient, '_get_config') +@unittest.mock.patch.object(EthereumClient, '_create_hub_contract') +def test_read_destination_transfer_correct_blocks_queried( + mocked_hub_contract, mocked_get_config, mocked_get_utilities, + blocks_queried_expected, ethereum_client): + mocked_get_utilities().create_node_connections().\ + eth.get_block_number().get_minimum_result.return_value = \ + blocks_queried_expected['last_block_number'] + mocked_get_config().__getitem__.return_value = \ + blocks_queried_expected['blocks_per_query'] + mocked_get_utilities().get_logs.return_value = [] + request = EthereumClient.DestinationTransferRequest( + Blockchain.ETHEREUM, '0x0', + blocks_queried_expected['blocks_to_search']) + + with pytest.raises(UnknownTransferError): + ethereum_client.read_destination_transfer(request) + + get_logs_blocks_queried = [] + for call_args in mocked_get_utilities().get_logs.call_args_list: + get_logs_blocks_queried.append((call_args[0][1], call_args[0][2])) + + assert blocks_queried_expected['expected'] == get_logs_blocks_queried + + +@unittest.mock.patch.object(EthereumClient, '_get_utilities') +@unittest.mock.patch.object(EthereumClient, '_get_config') +@unittest.mock.patch.object(EthereumClient, '_create_hub_contract') +def test_read_destination_transfer_unkown_transfer(mocked_hub_contract, + mocked_get_config, + mocked_get_utilities, + ethereum_client): + mocked_get_utilities().create_node_connections().eth.get_block_number( + ).get_minimum_result.return_value = 1000 + mocked_get_config().__getitem__.return_value = 5 + mocked_get_utilities().get_logs.return_value = [] + + request = EthereumClient.DestinationTransferRequest( + Blockchain.ETHEREUM, '0x0') + + with pytest.raises(UnknownTransferError): + ethereum_client.read_destination_transfer(request) + + +@unittest.mock.patch.object(EthereumClient, '_get_utilities', + side_effect=Exception) +def test_read_destination_transfer_error(mocked_get_utilities, + ethereum_client): + request = EthereumClient.DestinationTransferRequest( + Blockchain.ETHEREUM, '0x0') + + with pytest.raises(EthereumClientError): + ethereum_client.read_destination_transfer(request) diff --git a/tests/conftest.py b/tests/conftest.py index 2873baa..f13c5f4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,7 @@ -"""Shared fixtures for all pantos.client.lbrary.business package tests. +"""Shared fixtures for all pantos.client.lbrary package tests. """ +import hexbytes import pytest from pantos.common.blockchains.base import Blockchain from pantos.common.entities import BlockchainAddress @@ -10,6 +11,38 @@ from pantos.client.library.blockchains.base import BlockchainClient from pantos.client.library.constants import TOKEN_SYMBOL_PAN +_BLOCK_NUMBER = 1 + +_TRANSACTION_HASH = hexbytes.HexBytes( + '3273482cfbf640bd5a7056fc3fd418275d2537bb49638035e19f2c4ebcf2e3d9') + +_SOURCE_TRANSFER_ID = 2 + +_DESTINATION_TRANSFER_ID = 3 + +_SENDER = BlockchainAddress('0x4958c0CdDb1649e8da454657733BA7AeC7069765') + +_RECIPIENT = BlockchainAddress('0xDc825BC1Af2d4c02E9e2d03fF3b492A09d168124') + +_SOURCE_TOKEN = BlockchainAddress('0x57FeAEC5F8f3A19264d8DfF24a88dA9F774e30a2') + +_DESTINATION_TOKEN = BlockchainAddress( + '0x49716ea49473c8B1164d2F503e50319D629CFFC6') + +_AMOUNT = 100 + +_NONCE = 11111 + +_SIGNER_ADDRESSES = [ + BlockchainAddress('0xBb608811Bfc5fc3444863BC589C7e5F50DF1936a') +] + +_SIGNATURES = [ + hexbytes.HexBytes( + '665b95365f0724784d5c2792ca870ff4bf08b06590ac068f6f89ae7edf640bdd3' + 'aaa116b69b2e0927a3151de498f5f0131beafbadb6c12c1756baa532d931fa81c') +] + _SERVICE_NODE_1 = BlockchainAddress( '0x5188287E724140aa3C432dCfE69E00992aF09d09') @@ -91,3 +124,90 @@ def transfer_from_signature_request(): destination_token_address=_TOKEN_ADDRESS, token_amount=100, service_node_address=_SERVICE_NODE_1, service_node_bid=_BIDS_1[0], valid_until=1000) + + +@pytest.fixture(scope='module') +def source_transaction_id(): + return _TRANSACTION_HASH + + +@pytest.fixture(scope='module') +def block_number(): + return _BLOCK_NUMBER + + +@pytest.fixture(scope='module') +def transaction_hash(): + return _TRANSACTION_HASH + + +@pytest.fixture(scope='module') +def source_transfer_id(): + return _SOURCE_TRANSFER_ID + + +@pytest.fixture(scope='module') +def destination_transfer_id(): + return _DESTINATION_TRANSFER_ID + + +@pytest.fixture(scope='module') +def sender(): + return _SENDER + + +@pytest.fixture(scope='module') +def recipient(): + return _RECIPIENT + + +@pytest.fixture(scope='module') +def source_token(): + return _SOURCE_TOKEN + + +@pytest.fixture(scope='module') +def destination_token(): + return _DESTINATION_TOKEN + + +@pytest.fixture(scope='module') +def amount(): + return _AMOUNT + + +@pytest.fixture(scope='module') +def nonce(): + return _NONCE + + +@pytest.fixture(scope='module') +def signer_addresses(): + return _SIGNER_ADDRESSES + + +@pytest.fixture(scope='module') +def signatures(): + return _SIGNATURES + + +@pytest.fixture(scope='function') +def transfer_to_event(request): + return { + 'blockNumber': _BLOCK_NUMBER, + 'transactionHash': _TRANSACTION_HASH, + 'args': { + 'sourceBlockchainId': request.param.value, + 'sourceTransactionId': _TRANSACTION_HASH, + 'sourceTransferId': _SOURCE_TRANSFER_ID, + 'destinationTransferId': _DESTINATION_TRANSFER_ID, + 'sender': _SENDER, + 'recipient': _RECIPIENT, + 'sourceToken': _SOURCE_TOKEN, + 'destinationToken': _DESTINATION_TOKEN, + 'amount': _AMOUNT, + 'nonce': _NONCE, + 'signerAddresses': _SIGNER_ADDRESSES, + 'signatures': _SIGNATURES + } + }