diff --git a/docs/addresses.rst b/docs/addresses.rst index 6571e7f..8669c54 100644 --- a/docs/addresses.rst +++ b/docs/addresses.rst @@ -44,17 +44,17 @@ Using the API addresses = gna_result['addresses'] # Generate 1 address, starting with index 42: - gna_result = api.get_new_addresses(start=42) + gna_result = api.get_new_addresses(index=42) addresses = gna_result['addresses'] # Find the first unused address, starting with index 86: - gna_result = api.get_new_addresses(start=86, count=None) + gna_result = api.get_new_addresses(index=86, count=None) addresses = gna_result['addresses'] To generate addresses using the API, invoke its ``get_new_addresses`` method, using the following parameters: -- ``start: int``: The starting index (defaults to 0). This can be used +- ``index: int``: The starting index (defaults to 0). This can be used to skip over addresses that have already been generated. - ``count: Optional[int]``: The number of addresses to generate (defaults to 1). @@ -80,13 +80,13 @@ Using AddressGenerator generator = AddressGenerator(b'SEED9GOES9HERE') # Generate a list of addresses: - addresses = generator.get_addresses(start=0, count=5) + addresses = generator.get_addresses(index=0, count=5) # Generate a list of addresses in reverse order: - addresses = generator.get_addresses(start=42, count=10, step=-1) + addresses = generator.get_addresses(index=42, count=10, step=-1) # Create an iterator, advancing 5 indices each iteration. - iterator = generator.create_iterator(start=86, step=5) + iterator = generator.create_iterator(index=86, step=5) for address in iterator: ... diff --git a/examples/send_transfer.py b/examples/send_transfer.py index 011c553..91b92b9 100644 --- a/examples/send_transfer.py +++ b/examples/send_transfer.py @@ -5,7 +5,7 @@ from iota import * SEED1 = b"THESEEDOFTHEWALLETSENDINGGOESHERE999999999999999999999999999999999999999999999999" -ADDRESS_WITH_CHECKSUM_SECURITY_LEVEL_2 = b"RECEIVINGWALLETADDRESSGOESHERE9WITHCHECKSUMANDSECURITYLEVEL2999999999999999999999999999999" +ADDRESS_WITH_CHECKSUM_SECURITY_LEVEL_2 = b"RECEIVINGWALLETADDRESSGOESHERE9WITHCHECKSUMANDSECURITYLEVELB999999999999999999999999999999" # Create the API instance. api =\ diff --git a/iota/adapter/__init__.py b/iota/adapter/__init__.py index 63f8e2e..d5e77e9 100644 --- a/iota/adapter/__init__.py +++ b/iota/adapter/__init__.py @@ -1,6 +1,6 @@ # coding=utf-8 from __future__ import absolute_import, division, print_function, \ - unicode_literals + unicode_literals import json from abc import ABCMeta, abstractmethod as abstract_method @@ -10,28 +10,27 @@ from socket import getdefaulttimeout as get_default_timeout from typing import Container, Dict, List, Optional, Text, Tuple, Union -from requests import Response, codes, request, auth +from requests import Response, auth, codes, request from six import PY2, binary_type, iteritems, moves as compat, text_type, \ - with_metaclass + with_metaclass from iota.exceptions import with_context from iota.json import JsonEncoder __all__ = [ - 'API_VERSION', - 'AdapterSpec', - 'BadApiResponse', - 'InvalidUri', + 'API_VERSION', + 'AdapterSpec', + 'BadApiResponse', + 'InvalidUri', ] if PY2: - # Fix an error when importing this package using the ``imp`` library - # (note: ``imp`` is deprecated since Python 3.4 in favor of - # ``importlib``). - # https://docs.python.org/3/library/imp.html - # https://travis-ci.org/iotaledger/iota.lib.py/jobs/191974244 - __all__ = map(binary_type, __all__) - + # Fix an error when importing this package using the ``imp`` library + # (note: ``imp`` is deprecated since Python 3.4 in favor of + # ``importlib``). + # https://docs.python.org/3/library/imp.html + # https://travis-ci.org/iotaledger/iota.lib.py/jobs/191974244 + __all__ = map(binary_type, __all__) API_VERSION = '1' """ @@ -39,503 +38,513 @@ https://github.com/iotaledger/iota.lib.py/issues/84 """ - # Custom types for type hints and docstrings. AdapterSpec = Union[Text, 'BaseAdapter'] # Load SplitResult for IDE type hinting and autocompletion. if PY2: - # noinspection PyCompatibility,PyUnresolvedReferences - from urlparse import SplitResult + # noinspection PyCompatibility,PyUnresolvedReferences + from urlparse import SplitResult else: - # noinspection PyCompatibility,PyUnresolvedReferences - from urllib.parse import SplitResult + # noinspection PyCompatibility,PyUnresolvedReferences + from urllib.parse import SplitResult class BadApiResponse(ValueError): - """ - Indicates that a non-success response was received from the node. - """ - pass + """ + Indicates that a non-success response was received from the node. + """ + pass class InvalidUri(ValueError): - """ - Indicates that an invalid URI was provided to `resolve_adapter`. - """ - pass + """ + Indicates that an invalid URI was provided to `resolve_adapter`. + """ + pass -adapter_registry = {} # type: Dict[Text, AdapterMeta] +adapter_registry = {} # type: Dict[Text, AdapterMeta] """ Keeps track of available adapters and their supported protocols. """ def resolve_adapter(uri): - # type: (AdapterSpec) -> BaseAdapter - """ - Given a URI, returns a properly-configured adapter instance. - """ - if isinstance(uri, BaseAdapter): - return uri - - parsed = compat.urllib_parse.urlsplit(uri) # type: SplitResult - - if not parsed.scheme: - raise with_context( - exc = InvalidUri( - 'URI must begin with "://" (e.g., "udp://").', - ), - - context = { - 'parsed': parsed, - 'uri': uri, - }, - ) - - try: - adapter_type = adapter_registry[parsed.scheme] - except KeyError: - raise with_context( - exc = InvalidUri('Unrecognized protocol {protocol!r}.'.format( - protocol = parsed.scheme, - )), - - context = { - 'parsed': parsed, - 'uri': uri, - }, - ) - - return adapter_type.configure(parsed) - - -class AdapterMeta(ABCMeta): - """ - Automatically registers new adapter classes in ``adapter_registry``. - """ - # noinspection PyShadowingBuiltins - def __init__(cls, what, bases=None, dict=None): - super(AdapterMeta, cls).__init__(what, bases, dict) - - if not is_abstract(cls): - for protocol in getattr(cls, 'supported_protocols', ()): - # Note that we will not overwrite existing registered adapters. - adapter_registry.setdefault(protocol, cls) - - def configure(cls, parsed): - # type: (Union[Text, SplitResult]) -> HttpAdapter + # type: (AdapterSpec) -> BaseAdapter """ - Creates a new instance using the specified URI. - - :param parsed: - Result of :py:func:`urllib.parse.urlsplit`. + Given a URI, returns a properly-configured adapter instance. """ - return cls(parsed) + if isinstance(uri, BaseAdapter): + return uri + parsed = compat.urllib_parse.urlsplit(uri) # type: SplitResult -class BaseAdapter(with_metaclass(AdapterMeta)): - """ - Interface for IOTA API adapters. - - Adapters make it easy to customize the way an StrictIota instance - communicates with a node. - """ - supported_protocols = () # type: Tuple[Text] - """ - Protocols that ``resolve_adapter`` can use to identify this adapter - type. - """ - - def __init__(self): - super(BaseAdapter, self).__init__() - - self._logger = None # type: Logger - - @abstract_method - def get_uri(self): - # type: () -> Text - """ - Returns the URI that this adapter will use. - """ - raise NotImplementedError( - 'Not implemented in {cls}.'.format(cls=type(self).__name__), - ) + if not parsed.scheme: + raise with_context( + exc=InvalidUri( + 'URI must begin with "://" (e.g., "udp://").', + ), - @abstract_method - def send_request(self, payload, **kwargs): - # type: (dict, dict) -> dict - """ - Sends an API request to the node. + context={ + 'parsed': parsed, + 'uri': uri, + }, + ) - :param payload: - JSON payload. - - :param kwargs: - Additional keyword arguments for the adapter. - - :return: - Decoded response from the node. - - :raise: - - :py:class:`BadApiResponse` if a non-success response was - received. - """ - raise NotImplementedError( - 'Not implemented in {cls}.'.format(cls=type(self).__name__), - ) - - def set_logger(self, logger): - # type: (Logger) -> BaseAdapter - """ - Attaches a logger instance to the adapter. - The adapter will send information about API requests/responses to - the logger. - """ - self._logger = logger - return self + try: + adapter_type = adapter_registry[parsed.scheme] + except KeyError: + raise with_context( + exc=InvalidUri('Unrecognized protocol {protocol!r}.'.format( + protocol=parsed.scheme, + )), - def _log(self, level, message, context=None): - # type: (int, Text, Optional[dict]) -> None - """ - Sends a message to the instance's logger, if configured. - """ - if self._logger: - self._logger.log(level, message, extra={'context': context or {}}) + context={ + 'parsed': parsed, + 'uri': uri, + }, + ) + return adapter_type.configure(parsed) -class HttpAdapter(BaseAdapter): - """ - Sends standard HTTP requests. - """ - supported_protocols = ('http', 'https',) - - DEFAULT_HEADERS = { - 'Content-type': 'application/json', - - # https://github.com/iotaledger/iota.lib.py/issues/84 - 'X-IOTA-API-Version': API_VERSION, - } - """ - Default headers sent with every request. - These can be overridden on a per-request basis, by specifying values - in the ``headers`` kwarg. - """ - - def __init__(self, uri, timeout=None, authentication=None): - # type: (Union[Text, SplitResult], Optional[int]) -> None - super(HttpAdapter, self).__init__() - - self.timeout = timeout - self.authentication = authentication - - if isinstance(uri, text_type): - uri = compat.urllib_parse.urlsplit(uri) # type: SplitResult - - if uri.scheme not in self.supported_protocols: - raise with_context( - exc = InvalidUri('Unsupported protocol {protocol!r}.'.format( - protocol = uri.scheme, - )), - - context = { - 'uri': uri, - }, - ) - - if not uri.hostname: - raise with_context( - exc = InvalidUri( - 'Empty hostname in URI {uri!r}.'.format( - uri = uri.geturl(), - ), - ), - - context = { - 'uri': uri, - }, - ) - try: - # noinspection PyStatementEffect - uri.port - except ValueError: - raise with_context( - exc = InvalidUri( - 'Non-numeric port in URI {uri!r}.'.format( - uri = uri.geturl(), - ), - ), - - context = { - 'uri': uri, - }, - ) - - self.uri = uri - - @property - def node_url(self): - # type: () -> Text +class AdapterMeta(ABCMeta): """ - Returns the node URL. + Automatically registers new adapter classes in ``adapter_registry``. """ - return self.uri.geturl() - def get_uri(self): - # type: () -> Text - return self.uri.geturl() + # noinspection PyShadowingBuiltins + def __init__(cls, what, bases=None, dict=None): + super(AdapterMeta, cls).__init__(what, bases, dict) - def send_request(self, payload, **kwargs): - # type: (dict, dict) -> dict - kwargs.setdefault('headers', {}) - for key, value in iteritems(self.DEFAULT_HEADERS): - kwargs['headers'].setdefault(key, value) + if not is_abstract(cls): + for protocol in getattr(cls, 'supported_protocols', ()): + # Note that we will not overwrite existing registered + # adapters. + adapter_registry.setdefault(protocol, cls) - response = self._send_http_request( - # Use a custom JSON encoder that knows how to convert Tryte values. - payload = JsonEncoder().encode(payload), + def configure(cls, parsed): + # type: (Union[Text, SplitResult]) -> HttpAdapter + """ + Creates a new instance using the specified URI. - url = self.node_url, - **kwargs - ) + :param parsed: + Result of :py:func:`urllib.parse.urlsplit`. + """ + return cls(parsed) - return self._interpret_response(response, payload, {codes['ok']}) - def _send_http_request(self, url, payload, method='post', **kwargs): - # type: (Text, Optional[Text], Text, dict) -> Response +class BaseAdapter(with_metaclass(AdapterMeta)): """ - Sends the actual HTTP request. + Interface for IOTA API adapters. - Split into its own method so that it can be mocked during unit - tests. + Adapters make it easy to customize the way an StrictIota instance + communicates with a node. """ - - default_timeout = self.timeout if self.timeout else get_default_timeout() - kwargs.setdefault('timeout', default_timeout) - if self.authentication: - kwargs.setdefault('auth', auth.HTTPBasicAuth(*self.authentication)) - - self._log( - level = DEBUG, - - message = 'Sending {method} to {url}: {payload!r}'.format( - method = method, - payload = payload, - url = url, - ), - - context = { - 'request_method': method, - 'request_kwargs': kwargs, - 'request_payload': payload, - 'request_url': url, - }, - ) - - response = request(method=method, url=url, data=payload, **kwargs) - - self._log( - level = DEBUG, - - message = 'Receiving {method} from {url}: {response!r}'.format( - method = method, - response = response.content, - url = url, - ), - - context = { - 'request_method': method, - 'request_kwargs': kwargs, - 'request_payload': payload, - 'request_url': url, - - 'response_headers': response.headers, - 'response_content': response.content, - }, - ) - - return response - - def _interpret_response(self, response, payload, expected_status): - # type: (Response, dict, Container[int]) -> dict + supported_protocols = () # type: Tuple[Text] + """ + Protocols that ``resolve_adapter`` can use to identify this adapter + type. """ - Interprets the HTTP response from the node. - :param response: - The response object received from :py:meth:`_send_http_request`. + def __init__(self): + super(BaseAdapter, self).__init__() + + self._logger = None # type: Logger + + @abstract_method + def get_uri(self): + # type: () -> Text + """ + Returns the URI that this adapter will use. + """ + raise NotImplementedError( + 'Not implemented in {cls}.'.format(cls=type(self).__name__), + ) + + @abstract_method + def send_request(self, payload, **kwargs): + # type: (dict, dict) -> dict + """ + Sends an API request to the node. + + :param payload: + JSON payload. + + :param kwargs: + Additional keyword arguments for the adapter. + + :return: + Decoded response from the node. + + :raise: + - :py:class:`BadApiResponse` if a non-success response was + received. + """ + raise NotImplementedError( + 'Not implemented in {cls}.'.format(cls=type(self).__name__), + ) + + def set_logger(self, logger): + # type: (Logger) -> BaseAdapter + """ + Attaches a logger instance to the adapter. + The adapter will send information about API requests/responses + to the logger. + """ + self._logger = logger + return self + + def _log(self, level, message, context=None): + # type: (int, Text, Optional[dict]) -> None + """ + Sends a message to the instance's logger, if configured. + """ + if self._logger: + self._logger.log(level, message, extra={'context': context or {}}) - :param payload: - The request payload that was sent (used for debugging). - :param expected_status: - The response should match one of these status codes to be - considered valid. +class HttpAdapter(BaseAdapter): + """ + Sends standard HTTP requests. """ - raw_content = response.text - if not raw_content: - raise with_context( - exc = BadApiResponse( - 'Empty {status} response from node.'.format( - status = response.status_code, - ), - ), - - context = { - 'request': payload, - }, - ) + supported_protocols = ('http', 'https',) - try: - decoded = json.loads(raw_content) # type: dict - # :bc: py2k doesn't have JSONDecodeError - except ValueError: - raise with_context( - exc = BadApiResponse( - 'Non-JSON {status} response from node: {raw_content}'.format( - status = response.status_code, - raw_content = raw_content, - ) - ), - - context = { - 'request': payload, - 'raw_response': raw_content, - }, - ) - - if not isinstance(decoded, dict): - raise with_context( - exc = BadApiResponse( - 'Malformed {status} response from node: {decoded!r}'.format( - status = response.status_code, - decoded = decoded, - ), - ), - - context = { - 'request': payload, - 'response': decoded, - }, - ) - - if response.status_code in expected_status: - return decoded - - error = None - try: - if response.status_code == codes['bad_request']: - error = decoded['error'] - elif response.status_code == codes['internal_server_error']: - error = decoded['exception'] - except KeyError: - pass + DEFAULT_HEADERS = { + 'Content-type': 'application/json', - raise with_context( - exc = BadApiResponse( - '{status} response from node: {error}'.format( - error = error or decoded, - status = response.status_code, - ), - ), + # https://github.com/iotaledger/iota.lib.py/issues/84 + 'X-IOTA-API-Version': API_VERSION, + } + """ + Default headers sent with every request. + These can be overridden on a per-request basis, by specifying values + in the ``headers`` kwarg. + """ - context = { - 'request': payload, - 'response': decoded, - }, - ) + def __init__(self, uri, timeout=None, authentication=None): + # type: (Union[Text, SplitResult], Optional[int]) -> None + super(HttpAdapter, self).__init__() + + self.timeout = timeout + self.authentication = authentication + + if isinstance(uri, text_type): + uri = compat.urllib_parse.urlsplit(uri) # type: SplitResult + + if uri.scheme not in self.supported_protocols: + raise with_context( + exc=InvalidUri('Unsupported protocol {protocol!r}.'.format( + protocol=uri.scheme, + )), + + context={ + 'uri': uri, + }, + ) + + if not uri.hostname: + raise with_context( + exc=InvalidUri( + 'Empty hostname in URI {uri!r}.'.format( + uri=uri.geturl(), + ), + ), + + context={ + 'uri': uri, + }, + ) + + try: + # noinspection PyStatementEffect + uri.port + except ValueError: + raise with_context( + exc=InvalidUri( + 'Non-numeric port in URI {uri!r}.'.format( + uri=uri.geturl(), + ), + ), + + context={ + 'uri': uri, + }, + ) + + self.uri = uri + + @property + def node_url(self): + # type: () -> Text + """ + Returns the node URL. + """ + return self.uri.geturl() + + def get_uri(self): + # type: () -> Text + return self.uri.geturl() + + def send_request(self, payload, **kwargs): + # type: (dict, dict) -> dict + kwargs.setdefault('headers', {}) + for key, value in iteritems(self.DEFAULT_HEADERS): + kwargs['headers'].setdefault(key, value) + + response = self._send_http_request( + # Use a custom JSON encoder that knows how to convert Tryte + # values. + payload=JsonEncoder().encode(payload), + + url=self.node_url, + **kwargs + ) + + return self._interpret_response(response, payload, {codes['ok']}) + + def _send_http_request(self, url, payload, method='post', **kwargs): + # type: (Text, Optional[Text], Text, dict) -> Response + """ + Sends the actual HTTP request. + + Split into its own method so that it can be mocked during unit + tests. + """ + kwargs.setdefault( + 'timeout', + self.timeout if self.timeout else get_default_timeout(), + ) + + if self.authentication: + kwargs.setdefault('auth', auth.HTTPBasicAuth(*self.authentication)) + + self._log( + level=DEBUG, + + message='Sending {method} to {url}: {payload!r}'.format( + method=method, + payload=payload, + url=url, + ), + + context={ + 'request_method': method, + 'request_kwargs': kwargs, + 'request_payload': payload, + 'request_url': url, + }, + ) + + response = request(method=method, url=url, data=payload, **kwargs) + + self._log( + level=DEBUG, + + message='Receiving {method} from {url}: {response!r}'.format( + method=method, + response=response.content, + url=url, + ), + + context={ + 'request_method': method, + 'request_kwargs': kwargs, + 'request_payload': payload, + 'request_url': url, + + 'response_headers': response.headers, + 'response_content': response.content, + }, + ) + + return response + + def _interpret_response(self, response, payload, expected_status): + # type: (Response, dict, Container[int]) -> dict + """ + Interprets the HTTP response from the node. + + :param response: + The response object received from + :py:meth:`_send_http_request`. + + :param payload: + The request payload that was sent (used for debugging). + + :param expected_status: + The response should match one of these status codes to be + considered valid. + """ + raw_content = response.text + if not raw_content: + raise with_context( + exc=BadApiResponse( + 'Empty {status} response from node.'.format( + status=response.status_code, + ), + ), + + context={ + 'request': payload, + }, + ) + + try: + decoded = json.loads(raw_content) # type: dict + # :bc: py2k doesn't have JSONDecodeError + except ValueError: + raise with_context( + exc=BadApiResponse( + 'Non-JSON {status} response from node: ' + '{raw_content}'.format( + status=response.status_code, + raw_content=raw_content, + ) + ), + + context={ + 'request': payload, + 'raw_response': raw_content, + }, + ) + + if not isinstance(decoded, dict): + raise with_context( + exc=BadApiResponse( + 'Malformed {status} response from node: {decoded!r}'.format( + status=response.status_code, + decoded=decoded, + ), + ), + + context={ + 'request': payload, + 'response': decoded, + }, + ) + + if response.status_code in expected_status: + return decoded + + error = None + try: + if response.status_code == codes['bad_request']: + error = decoded['error'] + elif response.status_code == codes['internal_server_error']: + error = decoded['exception'] + except KeyError: + pass + + raise with_context( + exc=BadApiResponse( + '{status} response from node: {error}'.format( + error=error or decoded, + status=response.status_code, + ), + ), + + context={ + 'request': payload, + 'response': decoded, + }, + ) class MockAdapter(BaseAdapter): - """ - An mock adapter used for simulating API responses. - - To use this adapter, you must first "seed" the responses that the - adapter should return for each request. The adapter will then return - the appropriate seeded response each time it "sends" a request. - """ - supported_protocols = ('mock',) - - # noinspection PyUnusedLocal - @classmethod - def configure(cls, uri): - return cls() - - def __init__(self): - super(MockAdapter, self).__init__() - - self.responses = {} # type: Dict[Text, deque] - self.requests = [] # type: List[dict] - - def get_uri(self): - return 'mock://' - - def seed_response(self, command, response): - # type: (Text, dict) -> MockAdapter """ - Sets the response that the adapter will return for the specified - command. - - You can seed multiple responses per command; the adapter will put - them into a FIFO queue. When a request comes in, the adapter will - pop the corresponding response off of the queue. - - Example:: - - adapter.seed_response('sayHello', {'message': 'Hi!'}) - adapter.seed_response('sayHello', {'message': 'Hello!'}) - - adapter.send_request({'command': 'sayHello'}) - # {'message': 'Hi!'} + An mock adapter used for simulating API responses. - adapter.send_request({'command': 'sayHello'}) - # {'message': 'Hello!'} + To use this adapter, you must first "seed" the responses that the + adapter should return for each request. The adapter will then return + the appropriate seeded response each time it "sends" a request. """ - if command not in self.responses: - self.responses[command] = deque() - - self.responses[command].append(response) - return self - - def send_request(self, payload, **kwargs): - # type: (dict, dict) -> dict - # Store a snapshot so that we can inspect the request later. - self.requests.append(dict(payload)) - - command = payload['command'] - - try: - response = self.responses[command].popleft() - except KeyError: - raise with_context( - exc = BadApiResponse( - 'No seeded response for {command!r} ' - '(expected one of: {seeds!r}).'.format( - command = command, - seeds = list(sorted(self.responses.keys())), - ), - ), - - context = { - 'request': payload, - }, - ) - except IndexError: - raise with_context( - exc = BadApiResponse( - '{command} called too many times; no seeded responses left.'.format( - command = command, - ), - ), - - context = { - 'request': payload, - }, - ) - - error = response.get('exception') or response.get('error') - if error: - raise with_context(BadApiResponse(error), context={'request': payload}) - - return response + supported_protocols = ('mock',) + + # noinspection PyUnusedLocal + @classmethod + def configure(cls, uri): + return cls() + + def __init__(self): + super(MockAdapter, self).__init__() + + self.responses = {} # type: Dict[Text, deque] + self.requests = [] # type: List[dict] + + def get_uri(self): + return 'mock://' + + def seed_response(self, command, response): + # type: (Text, dict) -> MockAdapter + """ + Sets the response that the adapter will return for the specified + command. + + You can seed multiple responses per command; the adapter will + put them into a FIFO queue. When a request comes in, the + adapter will pop the corresponding response off of the queue. + + Example: + + .. code-block:: python + + adapter.seed_response('sayHello', {'message': 'Hi!'}) + adapter.seed_response('sayHello', {'message': 'Hello!'}) + + adapter.send_request({'command': 'sayHello'}) + # {'message': 'Hi!'} + + adapter.send_request({'command': 'sayHello'}) + # {'message': 'Hello!'} + """ + if command not in self.responses: + self.responses[command] = deque() + + self.responses[command].append(response) + return self + + def send_request(self, payload, **kwargs): + # type: (dict, dict) -> dict + # Store a snapshot so that we can inspect the request later. + self.requests.append(dict(payload)) + + command = payload['command'] + + try: + response = self.responses[command].popleft() + except KeyError: + raise with_context( + exc=BadApiResponse( + 'No seeded response for {command!r} ' + '(expected one of: {seeds!r}).'.format( + command=command, + seeds=list(sorted(self.responses.keys())), + ), + ), + + context={ + 'request': payload, + }, + ) + except IndexError: + raise with_context( + exc=BadApiResponse( + '{command} called too many times; ' + 'no seeded responses left.'.format( + command=command, + ), + ), + + context={ + 'request': payload, + }, + ) + + error = response.get('exception') or response.get('error') + if error: + raise with_context(BadApiResponse(error), + context={'request': payload}) + + return response diff --git a/iota/api.py b/iota/api.py index 349e535..7c2ad69 100644 --- a/iota/api.py +++ b/iota/api.py @@ -554,8 +554,8 @@ def get_bundles(self, transaction): """ return extended.GetBundlesCommand(self.adapter)(transaction=transaction) - def get_inputs(self, start=0, stop=None, threshold=None): - # type: (int, Optional[int], Optional[int]) -> dict + def get_inputs(self, start=0, stop=None, threshold=None, security_level=None): + # type: (int, Optional[int], Optional[int], Optional[int]) -> dict """ Gets all possible inputs of a seed and returns them with the total balance. @@ -595,6 +595,11 @@ def get_inputs(self, start=0, stop=None, threshold=None): If ``threshold`` is ``None`` (default), this method will return **all** inputs in the specified key range. + :param security_level: + Number of iterations to use when generating new addresses (see get_new_addresses). + This value must be between 1 and 3, inclusive. + If not set, defaults to AddressGenerator.DEFAULT_SECURITY_LEVEL = 2 + :return: Dict with the following structure:: @@ -629,6 +634,7 @@ def get_inputs(self, start=0, stop=None, threshold=None): start = start, stop = stop, threshold = threshold, + securityLevel=security_level ) def get_latest_inclusion(self, hashes): @@ -747,8 +753,8 @@ def get_transfers(self, start=0, stop=None, inclusion_states=False): inclusionStates = inclusion_states, ) - def prepare_transfer(self, transfers, inputs=None, change_address=None): - # type: (Iterable[ProposedTransaction], Optional[Iterable[Address]], Optional[Address]) -> dict + def prepare_transfer(self, transfers, inputs=None, change_address=None, security_level=None): + # type: (Iterable[ProposedTransaction], Optional[Iterable[Address]], Optional[Address], Optional[int]) -> dict """ Prepares transactions to be broadcast to the Tangle, by generating the correct bundle, as well as choosing and signing the inputs (for @@ -772,6 +778,11 @@ def prepare_transfer(self, transfers, inputs=None, change_address=None): If not specified, a change address will be generated automatically. + :param security_level: + Number of iterations to use when generating new addresses (see get_new_addresses). + This value must be between 1 and 3, inclusive. + If not set, defaults to AddressGenerator.DEFAULT_SECURITY_LEVEL = 2 + :return: Dict containing the following values:: @@ -789,6 +800,7 @@ def prepare_transfer(self, transfers, inputs=None, change_address=None): transfers = transfers, inputs = inputs, changeAddress = change_address, + securityLevel = security_level, ) def promote_transaction( @@ -869,6 +881,7 @@ def send_transfer( inputs = None, change_address = None, min_weight_magnitude = None, + security_level = None, ): # type: (int, Iterable[ProposedTransaction], Optional[Iterable[Address]], Optional[Address], Optional[int]) -> dict """ @@ -899,6 +912,11 @@ def send_transfer( If not provided, a default value will be used. + :param security_level: + Number of iterations to use when generating new addresses (see get_new_addresses). + This value must be between 1 and 3, inclusive. + If not set, defaults to AddressGenerator.DEFAULT_SECURITY_LEVEL = 2 + :return: Dict containing the following values:: @@ -920,6 +938,7 @@ def send_transfer( inputs = inputs, changeAddress = change_address, minWeightMagnitude = min_weight_magnitude, + securityLevel = security_level, ) def send_trytes(self, trytes, depth, min_weight_magnitude=None): @@ -964,7 +983,7 @@ def is_reattachable(self, addresses): # type: (Iterable[Address]) -> dict """ This API function helps you to determine whether you should replay a - transaction or make a completely new transaction with a different seed. + transaction or make a new one (either with the same input, or a different one). What this function does, is it takes one or more input addresses (i.e. from spent transactions) as input and then checks whether any transactions with a value transferred are confirmed. If yes, it means that this input address has already been successfully used in a different diff --git a/iota/commands/extended/get_inputs.py b/iota/commands/extended/get_inputs.py index 9897b55..77d62c1 100644 --- a/iota/commands/extended/get_inputs.py +++ b/iota/commands/extended/get_inputs.py @@ -5,6 +5,7 @@ from typing import Optional import filters as f + from iota import BadApiResponse from iota.commands import FilterCommand, RequestFilter from iota.commands.core.get_balances import GetBalancesCommand @@ -12,7 +13,7 @@ from iota.crypto.addresses import AddressGenerator from iota.crypto.types import Seed from iota.exceptions import with_context -from iota.filters import Trytes +from iota.filters import SecurityLevel, Trytes __all__ = [ 'GetInputsCommand', @@ -38,13 +39,14 @@ def _execute(self, request): seed = request['seed'] # type: Seed start = request['start'] # type: int threshold = request['threshold'] # type: Optional[int] + security_level = request['securityLevel'] # int # Determine the addresses we will be scanning. if stop is None: addresses =\ - [addy for addy, _ in iter_used_addresses(self.adapter, seed, start)] + [addy for addy, _ in iter_used_addresses(self.adapter, seed, start, security_level=security_level)] else: - addresses = AddressGenerator(seed).get_addresses(start, stop) + addresses = AddressGenerator(seed, security_level).get_addresses(start, stop - start) if addresses: # Load balances for the addresses that we generated. @@ -112,14 +114,17 @@ def __init__(self): 'start': f.Type(int) | f.Min(0) | f.Optional(0), 'threshold': f.Type(int) | f.Min(0), + 'securityLevel': SecurityLevel, + # These arguments are required. 'seed': f.Required | Trytes(result_type=Seed), }, - allow_missing_keys = { + allow_missing_keys={ 'stop', 'start', 'threshold', + 'securityLevel', } ) diff --git a/iota/commands/extended/get_new_addresses.py b/iota/commands/extended/get_new_addresses.py index 7acf1a8..552561d 100644 --- a/iota/commands/extended/get_new_addresses.py +++ b/iota/commands/extended/get_new_addresses.py @@ -6,12 +6,12 @@ import filters as f -from iota import Address, AddressChecksum +from iota import Address from iota.commands import FilterCommand, RequestFilter from iota.commands.core.find_transactions import FindTransactionsCommand from iota.crypto.addresses import AddressGenerator from iota.crypto.types import Seed -from iota.filters import Trytes +from iota.filters import SecurityLevel, Trytes __all__ = [ 'GetNewAddressesCommand', @@ -40,7 +40,7 @@ def _execute(self, request): seed = request['seed'] # type: Seed return { - 'addresses': + 'addresses': self._find_addresses(seed, index, count, security_level, checksum), } @@ -85,11 +85,7 @@ def __init__(self): 'count': f.Type(int) | f.Min(1), 'index': f.Type(int) | f.Min(0) | f.Optional(default=0), - 'securityLevel': - f.Type(int) - | f.Min(1) - | f.Max(self.MAX_SECURITY_LEVEL) - | f.Optional(default=AddressGenerator.DEFAULT_SECURITY_LEVEL), + 'securityLevel': SecurityLevel, 'seed': f.Required | Trytes(result_type=Seed), }, diff --git a/iota/commands/extended/prepare_transfer.py b/iota/commands/extended/prepare_transfer.py index cab9c7c..cd3cb90 100644 --- a/iota/commands/extended/prepare_transfer.py +++ b/iota/commands/extended/prepare_transfer.py @@ -5,6 +5,7 @@ from typing import List, Optional import filters as f + from iota import Address, BadApiResponse, ProposedBundle, \ ProposedTransaction from iota.commands import FilterCommand, RequestFilter @@ -14,7 +15,7 @@ from iota.crypto.signing import KeyGenerator from iota.crypto.types import Seed from iota.exceptions import with_context -from iota.filters import GeneratedAddress, Trytes +from iota.filters import GeneratedAddress, SecurityLevel, Trytes __all__ = [ 'PrepareTransferCommand', @@ -43,6 +44,7 @@ def _execute(self, request): # Optional parameters. change_address = request.get('changeAddress') # type: Optional[Address] proposed_inputs = request.get('inputs') # type: Optional[List[Address]] + security_level = request['securityLevel'] # type: int want_to_spend = bundle.balance if want_to_spend > 0: @@ -52,6 +54,7 @@ def _execute(self, request): gi_response = GetInputsCommand(self.adapter)( seed = seed, threshold = want_to_spend, + securityLevel=security_level, ) confirmed_inputs = gi_response['inputs'] @@ -99,7 +102,7 @@ def _execute(self, request): if bundle.balance < 0: if not change_address: change_address =\ - GetNewAddressesCommand(self.adapter)(seed=seed)['addresses'][0] + GetNewAddressesCommand(self.adapter)(seed=seed, securityLevel=security_level)['addresses'][0] bundle.send_unspent_inputs_to(change_address) @@ -130,6 +133,7 @@ def __init__(self): # Optional parameters. 'changeAddress': Trytes(result_type=Address), + 'securityLevel': SecurityLevel, # Note that ``inputs`` is allowed to be an empty array. 'inputs': @@ -139,5 +143,6 @@ def __init__(self): allow_missing_keys = { 'changeAddress', 'inputs', + 'securityLevel', }, ) diff --git a/iota/commands/extended/send_transfer.py b/iota/commands/extended/send_transfer.py index 99a9f39..7c5060d 100644 --- a/iota/commands/extended/send_transfer.py +++ b/iota/commands/extended/send_transfer.py @@ -5,12 +5,13 @@ from typing import List, Optional import filters as f + from iota import Address, Bundle, ProposedTransaction, TransactionHash from iota.commands import FilterCommand, RequestFilter from iota.commands.extended.prepare_transfer import PrepareTransferCommand from iota.commands.extended.send_trytes import SendTrytesCommand from iota.crypto.types import Seed -from iota.filters import Trytes +from iota.filters import SecurityLevel, Trytes __all__ = [ 'SendTransferCommand', @@ -39,12 +40,14 @@ def _execute(self, request): seed = request['seed'] # type: Seed transfers = request['transfers'] # type: List[ProposedTransaction] reference = request['reference'] # type: Optional[TransactionHash] + security_level = request['securityLevel'] # int pt_response = PrepareTransferCommand(self.adapter)( changeAddress = change_address, inputs = inputs, seed = seed, transfers = transfers, + securityLevel = security_level, ) st_response = SendTrytesCommand(self.adapter)( @@ -79,7 +82,7 @@ def __init__(self): # Optional parameters. 'changeAddress': Trytes(result_type=Address), - + 'securityLevel': SecurityLevel, # Note that ``inputs`` is allowed to be an empty array. 'inputs': @@ -92,5 +95,6 @@ def __init__(self): 'changeAddress', 'inputs', 'reference', + 'securityLevel', }, ) diff --git a/iota/commands/extended/send_trytes.py b/iota/commands/extended/send_trytes.py index b30f4d4..1b43d84 100644 --- a/iota/commands/extended/send_trytes.py +++ b/iota/commands/extended/send_trytes.py @@ -2,7 +2,7 @@ from __future__ import absolute_import, division, print_function, \ unicode_literals -from typing import List +from typing import List, Optional import filters as f from iota import TransactionTrytes, TryteString, TransactionHash diff --git a/iota/commands/extended/utils.py b/iota/commands/extended/utils.py index 1504407..a3a2807 100644 --- a/iota/commands/extended/utils.py +++ b/iota/commands/extended/utils.py @@ -2,7 +2,7 @@ from __future__ import absolute_import, division, print_function, \ unicode_literals -from typing import Generator, Iterable, List, Tuple +from typing import Generator, Iterable, List, Optional, Tuple from iota import Address, Bundle, Transaction, \ TransactionHash @@ -37,17 +37,20 @@ def find_transaction_objects(adapter, **kwargs): return [] -def iter_used_addresses(adapter, seed, start): - # type: (BaseAdapter, Seed, int) -> Generator[Tuple[Address, List[TransactionHash]]] +def iter_used_addresses(adapter, seed, start, security_level=None): + # type: (BaseAdapter, Seed, int, Optional[int]) -> Generator[Tuple[Address, List[TransactionHash]]] """ Scans the Tangle for used addresses. This is basically the opposite of invoking ``getNewAddresses`` with ``stop=None``. """ + if security_level is None: + security_level = AddressGenerator.DEFAULT_SECURITY_LEVEL + ft_command = FindTransactionsCommand(adapter) - for addy in AddressGenerator(seed).create_iterator(start): + for addy in AddressGenerator(seed, security_level=security_level).create_iterator(start): ft_response = ft_command(addresses=[addy]) if ft_response['hashes']: diff --git a/iota/filters.py b/iota/filters.py index e5bae6a..1c1ffdb 100644 --- a/iota/filters.py +++ b/iota/filters.py @@ -5,9 +5,11 @@ from typing import Text import filters as f +from filters.macros import filter_macro from six import binary_type, moves as compat, text_type from iota import Address, TryteString, TrytesCompatible +from iota.crypto.addresses import AddressGenerator class GeneratedAddress(f.BaseFilter): @@ -70,6 +72,19 @@ def _apply(self, value): return value +@filter_macro +def SecurityLevel(): + """ + Generates a filter chain for validating a security level. + """ + return ( + f.Type(int) | + f.Min(1) | + f.Max(3) | + f.Optional(default=AddressGenerator.DEFAULT_SECURITY_LEVEL) + ) + + class Trytes(f.BaseFilter): """ Validates a sequence as a sequence of trytes. diff --git a/setup.py b/setup.py index 13ae30b..444dfc9 100644 --- a/setup.py +++ b/setup.py @@ -43,7 +43,7 @@ name = 'PyOTA', description = 'IOTA API library for Python', url = 'https://github.com/iotaledger/iota.lib.py', - version = '2.0.5', + version = '2.0.6', long_description = long_description, diff --git a/test/commands/extended/get_inputs_test.py b/test/commands/extended/get_inputs_test.py index 793f114..de62d9f 100644 --- a/test/commands/extended/get_inputs_test.py +++ b/test/commands/extended/get_inputs_test.py @@ -9,8 +9,8 @@ from iota import Address, BadApiResponse, Iota, TransactionHash from iota.adapter import MockAdapter -from iota.commands.extended.get_inputs import GetInputsCommand, \ - GetInputsRequestFilter +from iota.commands.extended.get_inputs import GetInputsCommand, GetInputsRequestFilter +from iota.crypto.addresses import AddressGenerator from iota.crypto.types import Seed from iota.filters import Trytes from test import mock @@ -38,6 +38,7 @@ def test_pass_happy_path(self): 'start': 0, 'stop': 10, 'threshold': 100, + "securityLevel": 3, } filter_ = self._filter(request) @@ -59,6 +60,7 @@ def test_pass_compatible_types(self): 'start': 42, 'stop': 86, 'threshold': 99, + "securityLevel": 3, }) self.assertFilterPasses(filter_) @@ -70,6 +72,7 @@ def test_pass_compatible_types(self): 'start': 42, 'stop': 86, 'threshold': 99, + "securityLevel": 3, }, ) @@ -90,6 +93,7 @@ def test_pass_optional_parameters_excluded(self): 'start': 0, 'stop': None, 'threshold': None, + "securityLevel": AddressGenerator.DEFAULT_SECURITY_LEVEL, } ) @@ -351,6 +355,51 @@ def test_fail_threshold_too_small(self): }, ) + def test_fail_security_level_too_small(self): + """ + ``securityLevel`` is < 1. + """ + self.assertFilterErrors( + { + 'securityLevel': 0, + 'seed': Seed(self.seed), + }, + + { + 'securityLevel': [f.Min.CODE_TOO_SMALL], + }, + ) + + def test_fail_security_level_too_big(self): + """ + ``securityLevel`` is > 3. + """ + self.assertFilterErrors( + { + 'securityLevel': 4, + 'seed': Seed(self.seed), + }, + + { + 'securityLevel': [f.Max.CODE_TOO_BIG], + }, + ) + + def test_fail_security_level_wrong_type(self): + """ + ``securityLevel`` is not an int. + """ + self.assertFilterErrors( + { + 'securityLevel': '2', + 'seed': Seed(self.seed), + }, + + { + 'securityLevel': [f.Type.CODE_WRONG_TYPE], + }, + ) + class GetInputsCommandTestCase(TestCase): # noinspection SpellCheckingInspection @@ -818,3 +867,139 @@ def mock_address_generator(ag, start, step=1): self.assertEqual(input0, self.addy1) self.assertEqual(input0.balance, 86) self.assertEqual(input0.key_index, 1) + + def test_start_stop(self): + """ + Using ``start`` and ``stop`` at once. + Checking if correct number of addresses is returned. Must be stop - start + """ + + # To keep the unit test nice and speedy, we will mock the address + # generator. We already have plenty of unit tests for that + # functionality, so we can get away with mocking it here. + # noinspection PyUnusedLocal + + def mock_address_generator(ag, start, step=1): + # returning up to 3 addresses, depending on stop value + for addy in [self.addy0, self.addy1, self.addy2][start::step]: + yield addy + + self.adapter.seed_response('getBalances', { + 'balances': [11, 11], + }) + + with mock.patch( + 'iota.crypto.addresses.AddressGenerator.create_iterator', + mock_address_generator, + ): + response = self.command( + seed = Seed.random(), + start = 1, + stop = 3, + ) + + self.assertEqual(len(response['inputs']), 2) # 3 - 1 = 2 addresses expected + self.assertEqual(response['totalBalance'], 22) + + input0 = response['inputs'][0] + self.assertIsInstance(input0, Address) + self.assertEqual(input0, self.addy1) + self.assertEqual(input0.balance, 11) + self.assertEqual(input0.key_index, 1) + + input1 = response['inputs'][1] + self.assertIsInstance(input1, Address) + self.assertEqual(input1, self.addy2) + self.assertEqual(input1.balance, 11) + self.assertEqual(input1.key_index, 2) + + + def test_security_level_1_no_stop(self): + """ + Testing GetInputsCoommand: + - with security_level = 1 (non default) + - without `stop` parameter + """ + + # one address with index 0 for selected security levels for the random seed. + # to check with respective outputs from command + seed = Seed.random() + address = AddressGenerator(seed, security_level=1).get_addresses(0)[0] + + self.adapter.seed_response('getBalances', { + 'balances': [86], + }) + # ``getInputs`` uses ``findTransactions`` to identify unused + # addresses. + # noinspection SpellCheckingInspection + self.adapter.seed_response('findTransactions', { + 'hashes': [ + TransactionHash( + b'TESTVALUE9DONTUSEINPRODUCTION99999YFXGOD' + b'GISBJAX9PDJIRDMDV9DCRDCAEG9FN9KECCBDDFZ9H' + ), + ], + }) + self.adapter.seed_response('findTransactions', { + 'hashes': [], + }) + + response = GetInputsCommand(self.adapter)( + seed=seed, + securityLevel=1, + ) + + self.assertEqual(response['totalBalance'], 86) + self.assertEqual(len(response['inputs']), 1) + + input0 = response['inputs'][0] + self.assertIsInstance(input0, Address) + self.assertEqual(input0, address) + self.assertEqual(input0.balance, 86) + self.assertEqual(input0.key_index, 0) + + def test_security_level_1_with_stop(self): + """ + Testing GetInputsCoommand: + - with security_level = 1 (non default) + - with `stop` parameter + """ + + # one address with index 0 for selected security levels for the random seed. + # to check with respective outputs from command + seed = Seed.random() + address = AddressGenerator(seed, security_level=1).get_addresses(0)[0] + + self.adapter.seed_response('getBalances', { + 'balances': [86], + }) + # ``getInputs`` uses ``findTransactions`` to identify unused + # addresses. + # noinspection SpellCheckingInspection + self.adapter.seed_response('findTransactions', { + 'hashes': [ + TransactionHash( + b'TESTVALUE9DONTUSEINPRODUCTION99999YFXGOD' + b'GISBJAX9PDJIRDMDV9DCRDCAEG9FN9KECCBDDFZ9H' + ), + ], + }) + self.adapter.seed_response('findTransactions', { + 'hashes': [], + }) + + response = GetInputsCommand(self.adapter)( + seed=seed, + securityLevel=1, + stop=1, # <<<<< here + ) + + self.assertEqual(response['totalBalance'], 86) + self.assertEqual(len(response['inputs']), 1) + + input0 = response['inputs'][0] + self.assertIsInstance(input0, Address) + self.assertEqual(input0, address) + self.assertEqual(input0.balance, 86) + self.assertEqual(input0.key_index, 0) + diff --git a/test/commands/extended/prepare_transfer_test.py b/test/commands/extended/prepare_transfer_test.py index f1de60a..91f3544 100644 --- a/test/commands/extended/prepare_transfer_test.py +++ b/test/commands/extended/prepare_transfer_test.py @@ -9,9 +9,10 @@ from six import binary_type, iterkeys from iota import Address, BadApiResponse, Iota, ProposedTransaction, Tag, \ - TryteString + TryteString, Transaction, TransactionHash from iota.adapter import MockAdapter from iota.commands.extended.prepare_transfer import PrepareTransferCommand +from iota.crypto.addresses import AddressGenerator from iota.crypto.types import Seed from iota.filters import GeneratedAddress, Trytes from test import mock @@ -81,7 +82,7 @@ def test_pass_happy_path(self): Address(self.trytes3, key_index=3, security_level=2), Address(self.trytes4, key_index=4, security_level=2), ], - + 'securityLevel': 3 } filter_ = self._filter(request) @@ -108,6 +109,7 @@ def test_pass_compatible_types(self): # These still have to have the correct type, however. 'transfers': [self.transfer1, self.transfer2], + 'securityLevel': None }) self.assertFilterPasses(filter_) @@ -123,6 +125,7 @@ def test_pass_compatible_types(self): Address(self.trytes3), Address(self.trytes4), ], + 'securityLevel': AddressGenerator.DEFAULT_SECURITY_LEVEL }, ) @@ -146,6 +149,7 @@ def test_pass_optional_parameters_omitted(self): # These parameters are set to their default values. 'changeAddress': None, 'inputs': None, + "securityLevel": AddressGenerator.DEFAULT_SECURITY_LEVEL, }, ) @@ -395,6 +399,60 @@ def test_fail_inputs_contents_invalid(self): }, ) + def test_fail_security_level_too_small(self): + """ + ``securityLevel`` is < 1. + """ + self.assertFilterErrors( + { + 'transfers': [ + ProposedTransaction(address=Address(self.trytes2), value=42), + ], + 'securityLevel': 0, + 'seed': Seed(self.trytes1), + }, + + { + 'securityLevel': [f.Min.CODE_TOO_SMALL], + }, + ) + + def test_fail_security_level_too_big(self): + """ + ``securityLevel`` is > 3. + """ + self.assertFilterErrors( + { + 'transfers': [ + ProposedTransaction(address=Address(self.trytes2), value=42), + ], + 'securityLevel': 4, + 'seed': Seed(self.trytes1), + }, + + { + 'securityLevel': [f.Max.CODE_TOO_BIG], + }, + ) + + def test_fail_security_level_wrong_type(self): + """ + ``securityLevel`` is not an int. + """ + self.assertFilterErrors( + { + 'transfers': [ + ProposedTransaction(address=Address(self.trytes2), value=42), + ], + 'securityLevel': '2', + 'seed': Seed(self.trytes1), + }, + + { + 'securityLevel': [f.Type.CODE_WRONG_TYPE], + }, + ) + # noinspection SpellCheckingInspection class PrepareTransferCommandTestCase(TestCase): @@ -1216,3 +1274,157 @@ def test_pass_message_long(self): response['trytes'][2], TryteString('SGKETGDEEASG9GSGSFEASGZFSGAGSGTFSGSFTGVDSGSFEATGUDSGBGTGTDSGNFSGPFSGVFTGVDTGEETGUDTGHEEASGBGTGTDSGNFSGPFSGRFTGWDFAEASGZETGDESG9GQAEASGZFTGDEEASGTFSGVFSGPFSGSFSGZFEASGPFEASGZFSGVFTGTDSGSFQAEASGXFSGAGTGVDSGAGTGTDTGDESGWFEASGVFSGZFSGSFSGSFTGVDEATGUDTGVDSGSFSG9GTGDESAEASGQEEATGFETGVDSGVFEATGUDTGVDSGSFSG9GTGDEEASGRFSGAGSGYFSGTFSG9GTGDEEASGOFTGDETGVDTGEEEASGAGTGYDTGTDSGNFSG9GTGHETGGETGVDEASGYFTGGESGRFSGVFEATGUDEASGAGTGTDTGWDSGTFSGVFSGSFSGZFSAEASGSETGVDSGAGEASGOFTGWDSGRFSGSFTGVDEATGFETGVDSGAGEASGRFSGSFSGYFSGNFTGVDTGEEIBEASGKETGDEIBEASGKETGDEQAEASGYFSGSFSGWFTGVDSGSFSG9GSGNFSG9GTGVDEAFCTCXCBDQCTCFDVCIBEASGAFEASGZFSGSFSG9GTGHEEASGSFTGUDTGVDTGEEEASGOFSGAGSGYFTGEETGAESGNFTGHEEASGAGTGVDSGPFSGSFTGVDTGUDTGVDSGPFSGSFSG9GSG9GSGAGTGUDTGVDTGEEQAEATG9ESGSFSGZFEASGPFTGDEEASGZFSGAGSGTFSGSFTGVDSGSFEASGBGSGAGSG9GTGHETGVDTGEESAEASG9FTGDEEASGBGSGYFSGNFTG9ESGSFTGAETGEEEASGZESGNFSG9GTGVDTGEETGHESGQFSGAGEASGVFEASGBGTGTDSGAGSGXFSGYFTGHESG9GSGVFEASGZFSGAGTGTDTGUDSGXFSGVFTGYDEASGBGSGSFTGYDSGAGTGVDSGVFSG9GTGZDSGSFSGPFSAEASGAFEASGPFSGNFTGUDEASGSFTGUDTGVDTGEEEATGVDSGNFSGXFSGAGSGWFEATGTDSGAGTGUDSGXFSGAGTGAESGVFSAEASGAFEASGPFSGNFTGUDEASGSFTGUDTGVDTGEEEATGTDSGAGTGUDSGXFSGAGTGAETGEEQAEASG9GSGSFEASGUFSG9GSGNFTGHEQAEATG9ETGVDSGAGEATGHEEASGUFSG9GSGNFTGGEDBEATG9ETGVDSGAGEATGUDSGZFSGSFTGTDTGVDTGEEEASGZESGNFSG9GTGVDTGEETGHESGQFSGAGQAEASGPFEATGVDSGAGEASGPFTGTDSGSFSGZFTGHEEASGXFSGNFSGXFEATGVDTGTDSGNFSGQFSGVFTG9ESGSFTGUDSGXFSGVFSGWFQAEASGPFSGSFTGTDSGAGTGHETGVDSG9GSGAGQAEATGUDSGBGSGNFTGUDEASGTFSGVFSGUFSG9GTGEESAEASGQEEASGZFSGAGSGSFEATGUDTGWDTGBESGSFTGUDTGVDSGPFSGAGSGPFSGNFSG9GSGVFSGSFQAEASGPFEATGVDSGAGEASGPFTGTDSGSFSGZFTGHEEASGXFSGNFSGXFEASGQFTGTDSGAGTGVDSGSFTGUDSGXFEASGVFEASG9GSGSFSGBGSGAGSG9GTGHETGVDSG9GTGDESGZFSGVFEASGRFSGYFTGHEEASGPFSGNFTGUDQAEATGUDSGBGSGNFTGUDSGNFSGSFTGVDEASGTFSGVFSGUFSG9GSGVFEASASASAEASGKETGDEEASG9GSGSFEATGYDSGAGTGVDSGVFTGVDSGSFEASGUFSG9GSGNFTGVDTGEEEASGBGTGTDSGNFSGPFSGRFTGWDSAEASGXESGAGTGVDSGAGSGZFTGWDEATG9ETGVDSGAGEASGPFEASGQFSGYFTGWDSGOFSGVFSG9GSGSFEASGRFTGWDTGAESGVFQAEASGPFEATGVDSGSFTGYDEASGZFSGSFTGUDTGVDSGNFTGYDQAEASGPFTGDEEASG9GSGSFEASGQFSGAGSGPFSGAGTGTDSGVFTGVDSGSFEASGAGEASG9GSGNFEASGPFSGSFTG9ESGSFTGTDSGVFSG9GSGXFSGNFTGYDQAEASGPFTGDEEATGYDSGAGTGVDSGVFTGVDSGSFEASGZFSGSTESTVALUE9DONTUSEINPRODUCTION99999YMSWGXVNDMLXPT9HMVAOWUUZMLSJZFWGKDVGXPSQAWAEBJN999999999999999999999999999GFOTA9UNIT9TESTS99999999999NYBKIVD99999999999B99999999EKHBGESJFZXE9PY9UVFIPRHGGFKDFKQOQFKQAYISJOWCXIVBSGHOZGT9DZEQPPLTYHKTWBQZOFX9BEAID999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999PYOTA9UNIT9TESTS99999999999999999999999999999999999999999999999999999999999999999'), ) + + def test_security_level(self): + """ + testing use of security_level when inputs are given and change address is not given. + """ + # will be sending SEND_VALUE. + # balances of input addresses returned by the mock will be equal to SEND_VALUE + security_level * 11 + # expected result of the command depends on security_level + # will be testing for at least two security levels + + SECURITY_LEVELS_TO_TEST = [1, 2] # at least one is non-default. With [1,2,3] it takes much longer + SEND_VALUE = 42 + + # creating fake addresses, one for each security_level. + seed = Seed.random() + mock_addresses = {} + for sl in SECURITY_LEVELS_TO_TEST: + mock_addresses[sl] = Address( + trytes=Address.random(81), + key_index=0, + security_level=sl + ) + + # mock get_balances returns balance, depending on security_level of mock addresses + def mock_get_balances_execute(adapter, request): + # returns balances of input addresses equal to SEND_VALUE + security_level * 11 + addr = request["addresses"][0] + security_level = [l for l, a in mock_addresses.items() if str(a) == addr][0] + return dict(balances=[SEND_VALUE + security_level * 11], milestone=None) + + # testing for several security levels + for security_level in SECURITY_LEVELS_TO_TEST: + + # get_new_addresses uses `find_transactions` internaly. + # The following means requested address is considered unused + self.adapter.seed_response('findTransactions', { + 'hashes': [], + }) + + self.command.reset() + with mock.patch( + 'iota.commands.core.GetBalancesCommand._execute', + mock_get_balances_execute, + ): + response = \ + self.command( + seed=seed, + transfers=[ + ProposedTransaction( + address= + Address( + b'TESTVALUETWO9DONTUSEINPRODUCTION99999XYY' + b'NXZLKBYNFPXA9RUGZVEGVPLLFJEM9ZZOUINE9ONOW' + ), + value = SEND_VALUE, + ), + ], + inputs=[ + mock_addresses[security_level] + ], + securityLevel=security_level + ) + + self.assertEqual(set(iterkeys(response)), {'trytes'}) + + EXPECTED_NUMBER_OF_TX = 2 + security_level # signature requires as many transactions as security_level + EXPECTED_CHANGE_VALUE = security_level * 11 # what has left depends on security_level + + self.assertEqual(len(response['trytes']), EXPECTED_NUMBER_OF_TX) + + change_tx = Transaction.from_tryte_string(response['trytes'][0]) + self.assertEqual(change_tx.value, EXPECTED_CHANGE_VALUE) + + + def test_security_level_no_inputs(self): + """ + testing use of security_level when neither inputs nor change address is given. + """ + # will be sending SEND_VALUE. + # balances of input addresses returned by the mock will be equal to SEND_VALUE + security_level * 11 + # expected result of the command depends on security_level + # will be testing for at least two security levels + + SECURITY_LEVELS_TO_TEST = [1, 2] # at least one is non-default. With [1,2,3] it takes much longer + SEND_VALUE = 42 + + # pre-generating addresses, one for each security_level. + # they will be generated again by GetInputs internally + seed = Seed.random() + addresses = {} + for sl in SECURITY_LEVELS_TO_TEST: + addresses[sl] = AddressGenerator(seed, security_level=sl).get_addresses(0, count=1)[0] + + # mock get_balances returns balance, depending on security_level of mock addresses + def mock_get_balances_execute(adapter, request): + # returns balances of input addresses equal to SEND_VALUE + security_level * 11 + addr = request["addresses"][0] + security_level = [l for l, a in addresses.items() if str(a) == addr][0] + return dict(balances=[SEND_VALUE + security_level * 11], milestone=None) + + # testing several security levels + for security_level in SECURITY_LEVELS_TO_TEST: + + # get_inputs use iter_used_addresses and findTransactions. + # until address without tx found + self.adapter.seed_response('findTransactions', { + 'hashes': [ + TransactionHash( + b'TESTVALUE9DONTUSEINPRODUCTION99999YFXGOD' + b'GISBJAX9PDJIRDMDV9DCRDCAEG9FN9KECCBDDFZ9H' + ), + ], + }) + self.adapter.seed_response('findTransactions', { + 'hashes': [], + }) + + # get_new_addresses uses `find_transactions` internaly. + self.adapter.seed_response('findTransactions', { + 'hashes': [], + }) + + self.command.reset() + + with mock.patch( + 'iota.commands.core.GetBalancesCommand._execute', + mock_get_balances_execute, + ): + response = \ + self.command( + seed=seed, + transfers=[ + ProposedTransaction( + address= + Address( + b'TESTVALUETWO9DONTUSEINPRODUCTION99999XYY' + b'NXZLKBYNFPXA9RUGZVEGVPLLFJEM9ZZOUINE9ONOW' + ), + value=SEND_VALUE, + ), + ], + securityLevel=security_level + ) + + self.assertEqual(set(iterkeys(response)), {'trytes'}) + + EXPECTED_NUMBER_OF_TX = 2 + security_level # signature requires as many transactions as security_level + EXPECTED_CHANGE_VALUE = security_level * 11 # what has left depends on security_level + + self.assertEqual(len(response['trytes']), EXPECTED_NUMBER_OF_TX) + + change_tx = Transaction.from_tryte_string(response['trytes'][0]) + self.assertEqual(change_tx.value, EXPECTED_CHANGE_VALUE) + diff --git a/test/commands/extended/send_transfer_test.py b/test/commands/extended/send_transfer_test.py index 3853485..aaedac8 100644 --- a/test/commands/extended/send_transfer_test.py +++ b/test/commands/extended/send_transfer_test.py @@ -6,12 +6,13 @@ import filters as f from filters.test import BaseFilterTestCase -from six import binary_type, text_type +from six import binary_type -from iota import Address, Bundle, Iota, ProposedTransaction, \ - TransactionTrytes, TryteString, TransactionHash +from iota import Address, Bundle, Iota, ProposedTransaction, TransactionHash, \ + TransactionTrytes, TryteString from iota.adapter import MockAdapter from iota.commands.extended.send_transfer import SendTransferCommand +from iota.crypto.addresses import AddressGenerator from iota.crypto.types import Seed from iota.filters import Trytes from test import mock @@ -89,6 +90,7 @@ def test_pass_happy_path(self): ], 'reference': TransactionHash(self.trytes1), + 'securityLevel': AddressGenerator.DEFAULT_SECURITY_LEVEL, } filter_ = self._filter(request) @@ -120,6 +122,7 @@ def test_pass_compatible_types(self): 'depth': 100, 'minWeightMagnitude': 18, + 'securityLevel': None, }) self.assertFilterPasses(filter_) @@ -142,6 +145,7 @@ def test_pass_compatible_types(self): self.transfer1, self.transfer2 ], + 'securityLevel': AddressGenerator.DEFAULT_SECURITY_LEVEL } ) @@ -171,6 +175,7 @@ def test_pass_optional_parameters_omitted(self): 'depth': 100, 'minWeightMagnitude': 13, + 'securityLevel': AddressGenerator.DEFAULT_SECURITY_LEVEL, 'seed': Seed(self.trytes2), 'transfers': [ @@ -613,6 +618,49 @@ def test_fail_reference_not_trytes(self): }, ) + def test_fail_wrong_security_level(self): + """ + ``security_level`` is not one of integers 1, 2 or 3. + """ + self.assertFilterErrors( + { + 'depth': 100, + 'minWeightMagnitude': 18, + 'seed': Seed(self.trytes1), + 'transfers': [self.transfer1], + + # Maybe he's not that smart; maybe he's like a worker bee who + # only knows how to push buttons or something. + 'securityLevel': 0, + }, + + { + 'securityLevel': [f.Min.CODE_TOO_SMALL], + }, + ) + + def test_fail_wrong_security_level_type(self): + """ + ``security_level`` is not one of integers 1, 2 or 3. + """ + self.assertFilterErrors( + { + 'depth': 100, + 'minWeightMagnitude': 18, + 'seed': Seed(self.trytes1), + 'transfers': [self.transfer1], + + # Maybe he's not that smart; maybe he's like a worker bee who + # only knows how to push buttons or something. + 'securityLevel': "2", + }, + + { + 'securityLevel': [f.Type.CODE_WRONG_TYPE], + }, + ) + + class SendTransferCommandTestCase(TestCase): def setUp(self): @@ -637,48 +685,48 @@ def test_happy_path(self): # noinspection SpellCheckingInspection transaction1 =\ TransactionTrytes( - b'GYPRVHBEZOOFXSHQBLCYW9ICTCISLHDBNMMVYD9JJHQMPQCTIQAQTJNNNJ9IDXLRCC' - b'OYOXYPCLR9PBEY9ORZIEPPDNTI9CQWYZUOTAVBXPSBOFEQAPFLWXSWUIUSJMSJIIIZ' - b'WIKIRH9GCOEVZFKNXEVCUCIIWZQCQEUVRZOCMEL9AMGXJNMLJCIA9UWGRPPHCEOPTS' - b'VPKPPPCMQXYBHMSODTWUOABPKWFFFQJHCBVYXLHEWPD9YUDFTGNCYAKQKVEZYRBQRB' - b'XIAUX9SVEDUKGMTWQIYXRGSWYRK9SRONVGTW9YGHSZRIXWGPCCUCDRMAXBPDFVHSRY' - b'WHGB9DQSQFQKSNICGPIPTRZINYRXQAFSWSEWIFRMSBMGTNYPRWFSOIIWWT9IDSELM9' - b'JUOOWFNCCSHUSMGNROBFJX9JQ9XT9PKEGQYQAWAFPRVRRVQPUQBHLSNTEFCDKBWRCD' - b'X9EYOBB9KPMTLNNQLADBDLZPRVBCKVCYQEOLARJYAGTBFR9QLPKZBOYWZQOVKCVYRG' - b'YI9ZEFIQRKYXLJBZJDBJDJVQZCGYQMROVHNDBLGNLQODPUXFNTADDVYNZJUVPGB9LV' - b'PJIYLAPBOEHPMRWUIAJXVQOEM9ROEYUOTNLXVVQEYRQWDTQGDLEYFIYNDPRAIXOZEB' - b'CS9P99AZTQQLKEILEVXMSHBIDHLXKUOMMNFKPYHONKEYDCHMUNTTNRYVMMEYHPGASP' - b'ZXASKRUPWQSHDMU9VPS99ZZ9SJJYFUJFFMFORBYDILBXCAVJDPDFHTTTIYOVGLRDYR' - b'TKHXJORJVYRPTDH9ZCPZ9ZADXZFRSFPIQKWLBRNTWJHXTOAUOL9FVGTUMMPYGYICJD' - b'XMOESEVDJWLMCVTJLPIEKBE9JTHDQWV9MRMEWFLPWGJFLUXI9BXPSVWCMUWLZSEWHB' - b'DZKXOLYNOZAPOYLQVZAQMOHGTTQEUAOVKVRRGAHNGPUEKHFVPVCOYSJAWHZU9DRROH' - b'BETBAFTATVAUGOEGCAYUXACLSSHHVYDHMDGJP9AUCLWLNTFEVGQGHQXSKEMVOVSKQE' - b'EWHWZUDTYOBGCURRZSJZLFVQQAAYQO9TRLFFN9HTDQXBSPPJYXMNGLLBHOMNVXNOWE' - b'IDMJVCLLDFHBDONQJCJVLBLCSMDOUQCKKCQJMGTSTHBXPXAMLMSXRIPUBMBAWBFNLH' - b'LUJTRJLDERLZFUBUSMF999XNHLEEXEENQJNOFFPNPQ9PQICHSATPLZVMVIWLRTKYPI' - b'XNFGYWOJSQDAXGFHKZPFLPXQEHCYEAGTIWIJEZTAVLNUMAFWGGLXMBNUQTOFCNLJTC' - b'DMWVVZGVBSEBCPFSM99FLOIDTCLUGPSEDLOKZUAEVBLWNMODGZBWOVQT9DPFOTSKRA' - b'BQAVOQ9RXWBMAKFYNDCZOJGTCIDMQSQQSODKDXTPFLNOKSIZEOY9HFUTLQRXQMEPGO' - b'XQGLLPNSXAUCYPGZMNWMQWSWCKAQYKXJTWINSGPPZG9HLDLEAWUWEVCTVRCBDFOXKU' - b'ROXH9HXXAXVPEJFRSLOGRVGYZASTEBAQNXJJROCYRTDPYFUIQJVDHAKEG9YACV9HCP' - b'JUEUKOYFNWDXCCJBIFQKYOXGRDHVTHEQUMHO999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999999999999999999999999999999999999' - b'999999999999RKWEEVD99A99999999A99999999NFDPEEZCWVYLKZGSLCQNOFUSENI' - b'XRHWWTZFBXMPSQHEDFWZULBZFEOMNLRNIDQKDNNIELAOXOVMYEI9PGTKORV9IKTJZQ' - b'UBQAWTKBKZ9NEZHBFIMCLV9TTNJNQZUIJDFPTTCTKBJRHAITVSKUCUEMD9M9SQJ999' - b'999TKORV9IKTJZQUBQAWTKBKZ9NEZHBFIMCLV9TTNJNQZUIJDFPTTCTKBJRHAITVSK' - b'UCUEMD9M9SQJ999999999999999999999999999999999999999999999999999999' - b'999999999999999999999999999999999' - ) + b'GYPRVHBEZOOFXSHQBLCYW9ICTCISLHDBNMMVYD9JJHQMPQCTIQAQTJNNNJ9IDXLRCC' + b'OYOXYPCLR9PBEY9ORZIEPPDNTI9CQWYZUOTAVBXPSBOFEQAPFLWXSWUIUSJMSJIIIZ' + b'WIKIRH9GCOEVZFKNXEVCUCIIWZQCQEUVRZOCMEL9AMGXJNMLJCIA9UWGRPPHCEOPTS' + b'VPKPPPCMQXYBHMSODTWUOABPKWFFFQJHCBVYXLHEWPD9YUDFTGNCYAKQKVEZYRBQRB' + b'XIAUX9SVEDUKGMTWQIYXRGSWYRK9SRONVGTW9YGHSZRIXWGPCCUCDRMAXBPDFVHSRY' + b'WHGB9DQSQFQKSNICGPIPTRZINYRXQAFSWSEWIFRMSBMGTNYPRWFSOIIWWT9IDSELM9' + b'JUOOWFNCCSHUSMGNROBFJX9JQ9XT9PKEGQYQAWAFPRVRRVQPUQBHLSNTEFCDKBWRCD' + b'X9EYOBB9KPMTLNNQLADBDLZPRVBCKVCYQEOLARJYAGTBFR9QLPKZBOYWZQOVKCVYRG' + b'YI9ZEFIQRKYXLJBZJDBJDJVQZCGYQMROVHNDBLGNLQODPUXFNTADDVYNZJUVPGB9LV' + b'PJIYLAPBOEHPMRWUIAJXVQOEM9ROEYUOTNLXVVQEYRQWDTQGDLEYFIYNDPRAIXOZEB' + b'CS9P99AZTQQLKEILEVXMSHBIDHLXKUOMMNFKPYHONKEYDCHMUNTTNRYVMMEYHPGASP' + b'ZXASKRUPWQSHDMU9VPS99ZZ9SJJYFUJFFMFORBYDILBXCAVJDPDFHTTTIYOVGLRDYR' + b'TKHXJORJVYRPTDH9ZCPZ9ZADXZFRSFPIQKWLBRNTWJHXTOAUOL9FVGTUMMPYGYICJD' + b'XMOESEVDJWLMCVTJLPIEKBE9JTHDQWV9MRMEWFLPWGJFLUXI9BXPSVWCMUWLZSEWHB' + b'DZKXOLYNOZAPOYLQVZAQMOHGTTQEUAOVKVRRGAHNGPUEKHFVPVCOYSJAWHZU9DRROH' + b'BETBAFTATVAUGOEGCAYUXACLSSHHVYDHMDGJP9AUCLWLNTFEVGQGHQXSKEMVOVSKQE' + b'EWHWZUDTYOBGCURRZSJZLFVQQAAYQO9TRLFFN9HTDQXBSPPJYXMNGLLBHOMNVXNOWE' + b'IDMJVCLLDFHBDONQJCJVLBLCSMDOUQCKKCQJMGTSTHBXPXAMLMSXRIPUBMBAWBFNLH' + b'LUJTRJLDERLZFUBUSMF999XNHLEEXEENQJNOFFPNPQ9PQICHSATPLZVMVIWLRTKYPI' + b'XNFGYWOJSQDAXGFHKZPFLPXQEHCYEAGTIWIJEZTAVLNUMAFWGGLXMBNUQTOFCNLJTC' + b'DMWVVZGVBSEBCPFSM99FLOIDTCLUGPSEDLOKZUAEVBLWNMODGZBWOVQT9DPFOTSKRA' + b'BQAVOQ9RXWBMAKFYNDCZOJGTCIDMQSQQSODKDXTPFLNOKSIZEOY9HFUTLQRXQMEPGO' + b'XQGLLPNSXAUCYPGZMNWMQWSWCKAQYKXJTWINSGPPZG9HLDLEAWUWEVCTVRCBDFOXKU' + b'ROXH9HXXAXVPEJFRSLOGRVGYZASTEBAQNXJJROCYRTDPYFUIQJVDHAKEG9YACV9HCP' + b'JUEUKOYFNWDXCCJBIFQKYOXGRDHVTHEQUMHO999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999999999999999999999999999999999999' + b'999999999999RKWEEVD99A99999999A99999999NFDPEEZCWVYLKZGSLCQNOFUSENI' + b'XRHWWTZFBXMPSQHEDFWZULBZFEOMNLRNIDQKDNNIELAOXOVMYEI9PGTKORV9IKTJZQ' + b'UBQAWTKBKZ9NEZHBFIMCLV9TTNJNQZUIJDFPTTCTKBJRHAITVSKUCUEMD9M9SQJ999' + b'999TKORV9IKTJZQUBQAWTKBKZ9NEZHBFIMCLV9TTNJNQZUIJDFPTTCTKBJRHAITVSK' + b'UCUEMD9M9SQJ999999999999999999999999999999999999999999999999999999' + b'999999999999999999999999999999999' + ) mock_prepare_transfer =\ mock.Mock(return_value={