diff --git a/.travis.yml b/.travis.yml index c9d9918..2701940 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,7 +6,7 @@ python: - '3.6' install: - pip install . - - pip install docutils # Used to check package metadata. + - pip install docutils pygments # Used to check package metadata. script: - python setup.py check --strict --metadata --restructuredtext - nosetests diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index b7f9ab1..3570b2d 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -28,7 +28,8 @@ We're pretty open about how people contribute to PyOTA, but there are a few thin - Please do not post support requests here. Use the ``#iota-libs-pyota`` channel on `Slack`_ or post in the `forum`_ to ask for help. - Please do not propose new API methods here. There are multiple IOTA API libraries out there, and they must all have the same functionality. - - That said, if you have an idea for a new API method, please share it on the ``#developers`` channel in `Slack`_ so that IOTA Foundation members can evaluate it! + + - That said, if you have an idea for a new API method, please share it on the ``#developers`` channel in `Slack`_ so that IOTA Foundation members can evaluate it! Need Some Inspiration? @@ -54,29 +55,43 @@ Found a bug in the PyOTA code? Great! We can't fix bugs we don't know about; y Instructions ------------ 1. Make sure it really is a PyOTA bug. - - Check the traceback, and see if you can narrow down the cause of the bug. - - If the error is not directly caused by PyOTA, or if you are unable to figure out what is causing the problem, we're still here for for you! Post in the ``#iota-libs-pyota`` channel in `Slack`_ for assistance. + + - Check the traceback, and see if you can narrow down the cause of the bug. + - If the error is not directly caused by PyOTA, or if you are unable to figure out what is causing the problem, we're still here for for you! Post in the ``#iota-libs-pyota`` channel in `Slack`_ for assistance. + 2. Is it safe to publish details about this bug publicly? - - If the bug is security-related (e.g., could compromise a user's seed if exploited), or if it requires sensitive information in order to reproduce (e.g., the private key for an address), please do not post in in the PyOTA Bug Tracker! - - To report security-related bugs, please contact ``@phx`` directly in `Slack`_. + + - If the bug is security-related (e.g., could compromise a user's seed if exploited), or if it requires sensitive information in order to reproduce (e.g., the private key for an address), please do not post in in the PyOTA Bug Tracker! + - To report security-related bugs, please contact ``@phx`` directly in `Slack`_. + 3. Is this a known issue? - - Before posting a bug report, check the `PyOTA Bug Tracker`_ to see if there is an existing issue for this bug. + + - Before posting a bug report, check the `PyOTA Bug Tracker`_ to see if there is an existing issue for this bug. + 4. Create a new issue in the `PyOTA Bug Tracker`_. - - Be sure to include the following information: - - Which version of PyOTA you are using. - - Which version of Python you are using. - - Which operating system you are using. - - Instructions to reproduce the bug. - - The expected behavior, if applicable. - - The full exception traceback, if available. - - If the exception also has a context object, please include it. + + - Be sure to include the following information: + + - Which version of PyOTA you are using. + - Which version of Python you are using. + - Which operating system you are using. + - Instructions to reproduce the bug. + - The expected behavior, if applicable. + - The full exception traceback, if available. + - If the exception also has a context object, please include it. + 5. Please be nice! - - It's frustrating when things don't work the way you expect them to. We promise we didn't put that bug in there on purpose; we're all human, and we all make mistakes sometimes. + + - It's frustrating when things don't work the way you expect them to. We promise we didn't put that bug in there on purpose; we're all human, and we all make mistakes sometimes. + 6. Please be patient! - - We're committed to making to making PyOTA better, but we've also got jobs and other commitments. We'll respond as soon as we can, but it might be a few days. + + - We're committed to making to making PyOTA better, but we've also got jobs and other commitments. We'll respond as soon as we can, but it might be a few days. + 7. Please be responsive if follow-up is needed. - - We may request additional information to help us identify/fix the bug. The faster you respond to follow-up comments in your bug report, the sooner we can squash that bug! - - If someone adds a comment to your bug report, it will appear in the `Notifications`_ page in GitHub. You can also configure GitHub to `email you`_ when a new comment is posted. + + - We may request additional information to help us identify/fix the bug. The faster you respond to follow-up comments in your bug report, the sooner we can squash that bug! + - If someone adds a comment to your bug report, it will appear in the `Notifications`_ page in GitHub. You can also configure GitHub to `email you`_ when a new comment is posted. What You Can Expect ------------------- @@ -93,10 +108,14 @@ If you would like to contribute code to the PyOTA project, this section is for y Instructions ------------ 1. Find an issue in the `PyOTA Bug Tracker`_ to work on. - - If you want to work on a bug or feature that doesn't have a GitHub issue yet, create a new one before starting to work on it. That will give other developers an opportunity to provide feedback and/or suggest changes that will make it integrate better with the rest of the code. + + - If you want to work on a bug or feature that doesn't have a GitHub issue yet, create a new one before starting to work on it. That will give other developers an opportunity to provide feedback and/or suggest changes that will make it integrate better with the rest of the code. + 2. Create a fork of the PyOTA repository. 3. Create a new branch just for the bug/feature you are working on. - - If you want to work on multiple bugs/features, you can use branches to keep them separate, so that you can submit a separate Pull Request for each one. + + - If you want to work on multiple bugs/features, you can use branches to keep them separate, so that you can submit a separate Pull Request for each one. + 4. Once you have completed your work, create a Pull Request, ensuring that it meets the requirements listed below. Requirements for Pull Requests @@ -110,16 +129,24 @@ If you have any questions, please feel free to post in the ``#iota-libs-pyota`` - Please create Pull Requests against the ``develop`` branch. - Please limit each Pull Request to a single bugfix/enhancement. - Please limit the scope of each Pull Request to just the changes needed for that bugfix/enhancement. - - If you would like to refactor existing code, please create separate Pull Request(s) just for the refactoring. + + - If you would like to refactor existing code, please create separate Pull Request(s) just for the refactoring. + - Please ensure your code works in all supported versions of Python (this includes versions of Python 2 and Python 3). - - See ``README.rst`` for the list of supported Python versions. + + - See ``README.rst`` for the list of supported Python versions. + - Please ensure that your Pull Request includes full test coverage. - Please do not introduce new dependencies unless absolutely necessary. - When introducing new classes/functions, please write comprehensive and meaningful docstrings. It should be clear to anyone reading your code what your new class/function does and why it exists. - - Similarly, please be liberal about adding comments to your code. If you have any knowledge and/or had to do any research that would make your code easier to understand, add it as comment. Future developers will be very grateful for the extra context! - - Please ensure that your comments and docstrings use proper English grammar and spelling. + - Similarly, please be liberal about adding comments to your code. If you have any knowledge and/or had to do any research that would make your code easier to understand, add it as comment. Future developers will be very grateful for the extra context! + + - Please ensure that your comments and docstrings use proper English grammar and spelling. + - Please ensure that your code conforms to `PEP-8`_. - - You may deviate from PEP-8 if you feel that your changes improve readability, but be aware that you may be asked to justify your decision. + + - Much of the existing code is not currently formatted for PEP-8; where practical, you may prefer PEP-8 over being consistent with the existing code. + - We are currently converting the codebase over to PEP-8; `come on over and help us out!`_ What You Can Expect ------------------- @@ -129,6 +156,7 @@ When you submit a Pull Request, here is what you can expect from the individual - If any changes are needed, or if we cannot accept your submission, we will provide a respectful and constructive explanation. +.. _come on over and help us out!: https://github.com/iotaledger/iota.lib.py/issues/145 .. _email you: https://help.github.com/articles/managing-notification-delivery-methods/ .. _forum: https://forum.iota.org .. _help wanted: https://github.com/iotaledger/iota.lib.py/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22 diff --git a/README.rst b/README.rst index 71fd30b..a7b8dc0 100644 --- a/README.rst +++ b/README.rst @@ -98,7 +98,7 @@ can also build the documentation locally: make html .. _Create virtualenv: https://realpython.com/blog/python/python-virtual-environments-a-primer/ -.. _Discord: https://discordapp.com/invite/yxve4wu +.. _Discord: https://discord.gg/7Gu2mG5 .. _PyOTA Bug Tracker: https://github.com/iotaledger/iota.lib.py/issues .. _ReadTheDocs: https://pyota.readthedocs.io/ .. _dedicated forum: https://forum.iota.org/ diff --git a/iota/api.py b/iota/api.py index 66ce5b6..349e535 100644 --- a/iota/api.py +++ b/iota/api.py @@ -417,6 +417,22 @@ def store_transactions(self, trytes): """ return core.StoreTransactionsCommand(self.adapter)(trytes=trytes) + def were_addresses_spent_from(self, addresses): + # type: (Iterable[Address]) -> dict + """ + Check if a list of addresses was ever spent from, in the current + epoch, or in previous epochs. + + :param addresses: + List of addresses to check. + + References: + - https://iota.readme.io/docs/wereaddressesspentfrom + """ + return core.WereAddressesSpentFromCommand(self.adapter)( + addresses = addresses, + ) + class Iota(StrictIota): """ diff --git a/iota/bin/__init__.py b/iota/bin/__init__.py index c41cdf1..2a35967 100644 --- a/iota/bin/__init__.py +++ b/iota/bin/__init__.py @@ -8,7 +8,7 @@ from getpass import getpass as secure_input from io import StringIO from sys import exit -from typing import Optional, Text +from typing import Any, Optional, Text from six import text_type, with_metaclass @@ -39,7 +39,7 @@ def __init__(self, stdout=sys.stdout, stderr=sys.stderr, stdin=sys.stdin): @abstract_method def execute(self, api, **arguments): - # type: (Iota, ...) -> Optional[int] + # type: (Iota, **Any) -> Optional[int] """ Executes the command and (optionally) returns an exit code (used by the shell to determine if the application exited cleanly). diff --git a/iota/bin/repl.py b/iota/bin/repl.py index ac73da8..dcab831 100755 --- a/iota/bin/repl.py +++ b/iota/bin/repl.py @@ -89,7 +89,7 @@ def _start_repl(api): """ Starts the REPL. """ - _banner = ( + banner = ( 'IOTA API client for {uri} ({testnet}) initialized as variable `api`.\n' 'Type `help(api)` for list of API commands.'.format( testnet = 'testnet' if api.testnet else 'mainnet', @@ -97,16 +97,18 @@ def _start_repl(api): ) ) + scope_vars = {'api': api} + try: # noinspection PyUnresolvedReferences import IPython except ImportError: # IPython not available; use regular Python REPL. from code import InteractiveConsole - InteractiveConsole(locals={'api': api}).interact(_banner) + InteractiveConsole(locals=scope_vars).interact(banner, '') else: - # Launch IPython REPL. - IPython.embed(header=_banner) + print(banner) + IPython.start_ipython(argv=[], user_ns=scope_vars) def main(): diff --git a/iota/commands/core/__init__.py b/iota/commands/core/__init__.py index 73e27b5..6c16305 100644 --- a/iota/commands/core/__init__.py +++ b/iota/commands/core/__init__.py @@ -25,3 +25,4 @@ from .interrupt_attaching_to_tangle import * from .remove_neighbors import * from .store_transactions import * +from .were_addresses_spent_from import * diff --git a/iota/commands/core/were_addresses_spent_from.py b/iota/commands/core/were_addresses_spent_from.py new file mode 100644 index 0000000..317ee99 --- /dev/null +++ b/iota/commands/core/were_addresses_spent_from.py @@ -0,0 +1,44 @@ +# coding=utf-8 +from __future__ import absolute_import, division, print_function, \ + unicode_literals + +import filters as f + +from iota.commands import FilterCommand, RequestFilter +from iota.filters import AddressNoChecksum + +__all__ = [ + 'WereAddressesSpentFromCommand', +] + + +class WereAddressesSpentFromCommand(FilterCommand): + """ + Executes `wereAddressesSpentFrom` command. + + See :py:meth:`iota.api.StrictIota.were_addresses_spent_from`. + """ + command = 'wereAddressesSpentFrom' + + def get_request_filter(self): + return WereAddressesSpentFromRequestFilter() + + def get_response_filter(self): + pass + + +class WereAddressesSpentFromRequestFilter(RequestFilter): + def __init__(self): + super(WereAddressesSpentFromRequestFilter, self).__init__( + { + 'addresses': ( + f.Required + | f.Array + | f.FilterRepeater( + f.Required + | AddressNoChecksum() + | f.Unicode(encoding='ascii', normalize=False) + ) + ), + } + ) diff --git a/iota/crypto/types.py b/iota/crypto/types.py index 25f940c..0bf33c4 100644 --- a/iota/crypto/types.py +++ b/iota/crypto/types.py @@ -189,10 +189,10 @@ def get_digest(self): sponge.absorb(key_fragment) sponge.squeeze(hash_trits) - fragment_start = i * FRAGMENT_LENGTH - fragment_end = fragment_start + FRAGMENT_LENGTH + fragment_hash_start = i * HASH_LENGTH + fragment_hash_end = fragment_hash_start + HASH_LENGTH - digest[fragment_start:fragment_end] = hash_trits + digest[fragment_hash_start:fragment_hash_end] = hash_trits return Digest(TryteString.from_trits(digest), self.key_index) diff --git a/iota/transaction/creation.py b/iota/transaction/creation.py index 9d08829..9dd1d16 100644 --- a/iota/transaction/creation.py +++ b/iota/transaction/creation.py @@ -1,9 +1,9 @@ # coding=utf-8 from __future__ import absolute_import, division, print_function, \ - unicode_literals + unicode_literals from typing import Iterable, Iterator, List, MutableSequence, Optional, \ - Sequence, Tuple + Sequence, Tuple from six import PY2 @@ -19,81 +19,82 @@ from iota.types import Address, Tag, TryteString __all__ = [ - 'ProposedBundle', - 'ProposedTransaction', - 'Transfer', + 'ProposedBundle', + 'ProposedTransaction', + 'Transfer', ] class ProposedTransaction(Transaction): - """ - A transaction that has not yet been attached to the Tangle. - - Provide to :py:meth:`iota.api.Iota.send_transfer` to attach to - tangle and publish/store. - """ - def __init__(self, address, value, tag=None, message=None, timestamp=None): - # type: (Address, int, Optional[Tag], Optional[TryteString], Optional[int]) -> None - if not timestamp: - timestamp = get_current_timestamp() - - super(ProposedTransaction, self).__init__( - address = address, - tag = Tag(b'') if tag is None else tag, - timestamp = timestamp, - value = value, - - # These values will be populated when the bundle is finalized. - bundle_hash = None, - current_index = None, - hash_ = None, - last_index = None, - signature_message_fragment = None, - attachment_timestamp = 0, - attachment_timestamp_lower_bound = 0, - attachment_timestamp_upper_bound = 0, - - # These values start out empty; they will be populated when the - # node does PoW. - branch_transaction_hash = TransactionHash(b''), - nonce = Nonce(b''), - trunk_transaction_hash = TransactionHash(b''), - ) - - self.message = TryteString(b'') if message is None else message - - def as_tryte_string(self): - # type: () -> TryteString """ - Returns a TryteString representation of the transaction. - """ - if not self.bundle_hash: - raise with_context( - exc = RuntimeError( - 'Cannot get TryteString representation of {cls} instance ' - 'without a bundle hash; call ``bundle.finalize()`` first ' - '(``exc.context`` has more info).'.format( - cls = type(self).__name__, - ), - ), - - context = { - 'transaction': self, - }, - ) - - return super(ProposedTransaction, self).as_tryte_string() - - def increment_legacy_tag(self): - """ - Increments the transaction's legacy tag, used to fix insecure - bundle hashes when finalizing a bundle. + A transaction that has not yet been attached to the Tangle. + + Provide to :py:meth:`iota.api.Iota.send_transfer` to attach to + tangle and publish/store. + """ + + def __init__(self, address, value, tag=None, message=None, timestamp=None): + # type: (Address, int, Optional[Tag], Optional[TryteString], Optional[int]) -> None + if not timestamp: + timestamp = get_current_timestamp() + + super(ProposedTransaction, self).__init__( + address=address, + tag=Tag(b'') if tag is None else Tag(tag), + timestamp=timestamp, + value=value, + + # These values will be populated when the bundle is finalized. + bundle_hash=None, + current_index=None, + hash_=None, + last_index=None, + signature_message_fragment=None, + attachment_timestamp=0, + attachment_timestamp_lower_bound=0, + attachment_timestamp_upper_bound=0, + + # These values start out empty; they will be populated when the + # node does PoW. + branch_transaction_hash=TransactionHash(b''), + nonce=Nonce(b''), + trunk_transaction_hash=TransactionHash(b''), + ) - References: - - https://github.com/iotaledger/iota.lib.py/issues/84 - """ - self._legacy_tag =\ - Tag.from_trits(add_trits(self.legacy_tag.as_trits(), [1])) + self.message = TryteString(b'') if message is None else message + + def as_tryte_string(self): + # type: () -> TryteString + """ + Returns a TryteString representation of the transaction. + """ + if not self.bundle_hash: + raise with_context( + exc=RuntimeError( + 'Cannot get TryteString representation of {cls} instance ' + 'without a bundle hash; call ``bundle.finalize()`` first ' + '(``exc.context`` has more info).'.format( + cls=type(self).__name__, + ), + ), + + context={ + 'transaction': self, + }, + ) + + return super(ProposedTransaction, self).as_tryte_string() + + def increment_legacy_tag(self): + """ + Increments the transaction's legacy tag, used to fix insecure + bundle hashes when finalizing a bundle. + + References: + - https://github.com/iotaledger/iota.lib.py/issues/84 + """ + self._legacy_tag =\ + Tag.from_trits(add_trits(self.legacy_tag.as_trits(), [1])) Transfer = ProposedTransaction @@ -104,365 +105,366 @@ def increment_legacy_tag(self): class ProposedBundle(Bundle, Sequence[ProposedTransaction]): - """ - A collection of proposed transactions, to be treated as an atomic - unit when attached to the Tangle. - """ - def __init__(self, transactions=None, inputs=None, change_address=None): - # type: (Optional[Iterable[ProposedTransaction]], Optional[Iterable[Address]], Optional[Address]) -> None - super(ProposedBundle, self).__init__() - - self._transactions = [] # type: List[ProposedTransaction] - - if transactions: - for t in transactions: - self.add_transaction(t) - - if inputs: - self.add_inputs(inputs) - - self.change_address = change_address - - def __bool__(self): - # type: () -> bool - """ - Returns whether this bundle has any transactions. - """ - return bool(self._transactions) - - # :bc: Magic methods have different names in Python 2. - if PY2: - __nonzero__ = __bool__ - - def __contains__(self, transaction): - # type: (ProposedTransaction) -> bool - return transaction in self._transactions - - def __getitem__(self, index): - # type: (int) -> ProposedTransaction - """ - Returns the transaction at the specified index. - """ - return self._transactions[index] - - def __iter__(self): - # type: () -> Iterator[ProposedTransaction] - """ - Iterates over transactions in the bundle. - """ - return iter(self._transactions) - - def __len__(self): - # type: () -> int - """ - Returns te number of transactions in the bundle. - """ - return len(self._transactions) - - @property - def balance(self): - # type: () -> int - """ - Returns the bundle balance. - In order for a bundle to be valid, its balance must be 0: - - - A positive balance means that there aren't enough inputs to - cover the spent amount. - Add more inputs using :py:meth:`add_inputs`. - - A negative balance means that there are unspent inputs. - Use :py:meth:`send_unspent_inputs_to` to send the unspent - inputs to a "change" address. - """ - return sum(t.value for t in self._transactions) - - @property - def tag(self): - # type: () -> Tag - """ - Determines the most relevant tag for the bundle. - """ - for txn in reversed(self): # type: ProposedTransaction - if txn.tag: - # noinspection PyTypeChecker - return txn.tag - - return Tag(b'') - - def as_json_compatible(self): - # type: () -> List[dict] - """ - Returns a JSON-compatible representation of the object. - - References: - - :py:class:`iota.json.JsonEncoder`. - """ - return [txn.as_json_compatible() for txn in self] - - def add_transaction(self, transaction): - # type: (ProposedTransaction) -> None """ - Adds a transaction to the bundle. - - If the transaction message is too long, it will be split - automatically into multiple transactions. - """ - if self.hash: - raise RuntimeError('Bundle is already finalized.') - - if transaction.value < 0: - raise ValueError('Use ``add_inputs`` to add inputs to the bundle.') - - self._transactions.append(ProposedTransaction( - address = transaction.address, - value = transaction.value, - tag = transaction.tag, - message = transaction.message[:Fragment.LEN], - timestamp = transaction.timestamp, - )) - - # If the message is too long to fit in a single transactions, - # it must be split up into multiple transactions so that it will - # fit. - fragment = transaction.message[Fragment.LEN:] - while fragment: - self._transactions.append(ProposedTransaction( - address = transaction.address, - value = 0, - tag = transaction.tag, - message = fragment[:Fragment.LEN], - timestamp = transaction.timestamp, - )) - - fragment = fragment[Fragment.LEN:] - - def add_inputs(self, inputs): - # type: (Iterable[Address]) -> None - """ - Adds inputs to spend in the bundle. - - Note that each input may require multiple transactions, in order to - hold the entire signature. - - :param inputs: - Addresses to use as the inputs for this bundle. - - IMPORTANT: Must have ``balance`` and ``key_index`` attributes! - Use :py:meth:`iota.api.get_inputs` to prepare inputs. - """ - if self.hash: - raise RuntimeError('Bundle is already finalized.') - - for addy in inputs: - if addy.balance is None: - raise with_context( - exc = ValueError( - 'Address {address} has null ``balance`` ' - '(``exc.context`` has more info).'.format( - address = addy, - ), - ), - - context = { - 'address': addy, - }, - ) - - if addy.key_index is None: - raise with_context( - exc = ValueError( - 'Address {address} has null ``key_index`` ' - '(``exc.context`` has more info).'.format( - address = addy, - ), - ), - - context = { - 'address': addy, - }, - ) - - self._create_input_transactions(addy) - - def send_unspent_inputs_to(self, address): - # type: (Address) -> None - """ - Adds a transaction to send "change" (unspent inputs) to the - specified address. - - If the bundle has no unspent inputs, this method does nothing. - """ - if self.hash: - raise RuntimeError('Bundle is already finalized.') - - self.change_address = address - - def finalize(self): - # type: () -> None - """ - Finalizes the bundle, preparing it to be attached to the Tangle. - """ - if self.hash: - raise RuntimeError('Bundle is already finalized.') - - if not self: - raise ValueError('Bundle has no transactions.') - - # Quick validation. - balance = self.balance - - if balance < 0: - if self.change_address: - self.add_transaction(ProposedTransaction( - address = self.change_address, - value = -balance, - tag = self.tag, + A collection of proposed transactions, to be treated as an atomic + unit when attached to the Tangle. + """ + + def __init__(self, transactions=None, inputs=None, change_address=None): + # type: (Optional[Iterable[ProposedTransaction]], Optional[Iterable[Address]], Optional[Address]) -> None + super(ProposedBundle, self).__init__() + + self._transactions = [] # type: List[ProposedTransaction] + + if transactions: + for t in transactions: + self.add_transaction(t) + + if inputs: + self.add_inputs(inputs) + + self.change_address = change_address + + def __bool__(self): + # type: () -> bool + """ + Returns whether this bundle has any transactions. + """ + return bool(self._transactions) + + # :bc: Magic methods have different names in Python 2. + if PY2: + __nonzero__ = __bool__ + + def __contains__(self, transaction): + # type: (ProposedTransaction) -> bool + return transaction in self._transactions + + def __getitem__(self, index): + # type: (int) -> ProposedTransaction + """ + Returns the transaction at the specified index. + """ + return self._transactions[index] + + def __iter__(self): + # type: () -> Iterator[ProposedTransaction] + """ + Iterates over transactions in the bundle. + """ + return iter(self._transactions) + + def __len__(self): + # type: () -> int + """ + Returns te number of transactions in the bundle. + """ + return len(self._transactions) + + @property + def balance(self): + # type: () -> int + """ + Returns the bundle balance. + In order for a bundle to be valid, its balance must be 0: + + - A positive balance means that there aren't enough inputs to + cover the spent amount. + Add more inputs using :py:meth:`add_inputs`. + - A negative balance means that there are unspent inputs. + Use :py:meth:`send_unspent_inputs_to` to send the unspent + inputs to a "change" address. + """ + return sum(t.value for t in self._transactions) + + @property + def tag(self): + # type: () -> Tag + """ + Determines the most relevant tag for the bundle. + """ + for txn in reversed(self): # type: ProposedTransaction + if txn.tag: + # noinspection PyTypeChecker + return txn.tag + + return Tag(b'') + + def as_json_compatible(self): + # type: () -> List[dict] + """ + Returns a JSON-compatible representation of the object. + + References: + - :py:class:`iota.json.JsonEncoder`. + """ + return [txn.as_json_compatible() for txn in self] + + def add_transaction(self, transaction): + # type: (ProposedTransaction) -> None + """ + Adds a transaction to the bundle. + + If the transaction message is too long, it will be split + automatically into multiple transactions. + """ + if self.hash: + raise RuntimeError('Bundle is already finalized.') + + if transaction.value < 0: + raise ValueError('Use ``add_inputs`` to add inputs to the bundle.') + + self._transactions.append(ProposedTransaction( + address=transaction.address, + value=transaction.value, + tag=transaction.tag, + message=transaction.message[:Fragment.LEN], + timestamp=transaction.timestamp, )) - else: - raise ValueError( - 'Bundle has unspent inputs (balance: {balance}); ' - 'use ``send_unspent_inputs_to`` to create ' - 'change transaction.'.format( - balance = balance, - ), - ) - elif balance > 0: - raise ValueError( - 'Inputs are insufficient to cover bundle spend ' - '(balance: {balance}).'.format( - balance = balance, - ), - ) - - # Generate bundle hash. - while True: - sponge = Kerl() - last_index = len(self) - 1 - - for (i, txn) in enumerate(self): # type: Tuple[int, ProposedTransaction] - txn.current_index = i - txn.last_index = last_index - - sponge.absorb(txn.get_signature_validation_trytes().as_trits()) - - bundle_hash_trits = [0] * HASH_LENGTH # type: MutableSequence[int] - sponge.squeeze(bundle_hash_trits) - - bundle_hash = BundleHash.from_trits(bundle_hash_trits) - - # Check that we generated a secure bundle hash. - # https://github.com/iotaledger/iota.lib.py/issues/84 - if any(13 in part for part in normalize(bundle_hash)): - # Increment the legacy tag and try again. - tail_transaction = self.tail_transaction # type: ProposedTransaction - tail_transaction.increment_legacy_tag() - else: - break - - # Copy bundle hash to individual transactions. - for txn in self: - txn.bundle_hash = bundle_hash - - # Initialize signature/message fragment. - txn.signature_message_fragment = Fragment(txn.message or b'') - - def sign_inputs(self, key_generator): - # type: (KeyGenerator) -> None - """ - Sign inputs in a finalized bundle. - """ - if not self.hash: - raise RuntimeError('Cannot sign inputs until bundle is finalized.') - - # Use a counter for the loop so that we can skip ahead as we go. - i = 0 - while i < len(self): - txn = self[i] - - if txn.value < 0: - # In order to sign the input, we need to know the index of - # the private key used to generate it. - if txn.address.key_index is None: - raise with_context( - exc = ValueError( - 'Unable to sign input {input}; ``key_index`` is None ' - '(``exc.context`` has more info).'.format( - input = txn.address, - ), - ), - - context = { - 'transaction': txn, - }, - ) - - if txn.address.security_level is None: - raise with_context( - exc = ValueError( - 'Unable to sign input {input}; ``security_level`` is None ' - '(``exc.context`` has more info).'.format( - input = txn.address, - ), - ), - - context = { - 'transaction': txn, - }, - ) - - self.sign_input_at(i, key_generator.get_key_for(txn.address)) - - i += txn.address.security_level - else: - # No signature needed (nor even possible, in some cases); skip - # this transaction. - i += 1 - - def sign_input_at(self, start_index, private_key): - # type: (int, PrivateKey) -> None - """ - Signs the input at the specified index. - :param start_index: - The index of the first input transaction. - - If necessary, the resulting signature will be split across - multiple transactions automatically (i.e., if an input has - ``security_level=2``, you still only need to call - :py:meth:`sign_input_at` once). - - :param private_key: - The private key that will be used to generate the signature. - - Important: be sure that the private key was generated using the - correct seed, or the resulting signature will be invalid! - """ - if not self.hash: - raise RuntimeError('Cannot sign inputs until bundle is finalized.') - - private_key.sign_input_transactions(self, start_index) + # If the message is too long to fit in a single transactions, + # it must be split up into multiple transactions so that it will + # fit. + fragment = transaction.message[Fragment.LEN:] + while fragment: + self._transactions.append(ProposedTransaction( + address=transaction.address, + value=0, + tag=transaction.tag, + message=fragment[:Fragment.LEN], + timestamp=transaction.timestamp, + )) + + fragment = fragment[Fragment.LEN:] + + def add_inputs(self, inputs): + # type: (Iterable[Address]) -> None + """ + Adds inputs to spend in the bundle. + + Note that each input may require multiple transactions, in order to + hold the entire signature. + + :param inputs: + Addresses to use as the inputs for this bundle. + + IMPORTANT: Must have ``balance`` and ``key_index`` attributes! + Use :py:meth:`iota.api.get_inputs` to prepare inputs. + """ + if self.hash: + raise RuntimeError('Bundle is already finalized.') + + for addy in inputs: + if addy.balance is None: + raise with_context( + exc=ValueError( + 'Address {address} has null ``balance`` ' + '(``exc.context`` has more info).'.format( + address=addy, + ), + ), + + context={ + 'address': addy, + }, + ) + + if addy.key_index is None: + raise with_context( + exc=ValueError( + 'Address {address} has null ``key_index`` ' + '(``exc.context`` has more info).'.format( + address=addy, + ), + ), + + context={ + 'address': addy, + }, + ) + + self._create_input_transactions(addy) + + def send_unspent_inputs_to(self, address): + # type: (Address) -> None + """ + Adds a transaction to send "change" (unspent inputs) to the + specified address. + + If the bundle has no unspent inputs, this method does nothing. + """ + if self.hash: + raise RuntimeError('Bundle is already finalized.') + + self.change_address = address + + def finalize(self): + # type: () -> None + """ + Finalizes the bundle, preparing it to be attached to the Tangle. + """ + if self.hash: + raise RuntimeError('Bundle is already finalized.') + + if not self: + raise ValueError('Bundle has no transactions.') + + # Quick validation. + balance = self.balance + + if balance < 0: + if self.change_address: + self.add_transaction(ProposedTransaction( + address=self.change_address, + value=-balance, + tag=self.tag, + )) + else: + raise ValueError( + 'Bundle has unspent inputs (balance: {balance}); ' + 'use ``send_unspent_inputs_to`` to create ' + 'change transaction.'.format( + balance=balance, + ), + ) + elif balance > 0: + raise ValueError( + 'Inputs are insufficient to cover bundle spend ' + '(balance: {balance}).'.format( + balance=balance, + ), + ) + + # Generate bundle hash. + while True: + sponge = Kerl() + last_index = len(self) - 1 + + for (i, txn) in enumerate(self): # type: Tuple[int, ProposedTransaction] + txn.current_index = i + txn.last_index = last_index + + sponge.absorb(txn.get_signature_validation_trytes().as_trits()) + + bundle_hash_trits = [0] * HASH_LENGTH # type: MutableSequence[int] + sponge.squeeze(bundle_hash_trits) + + bundle_hash = BundleHash.from_trits(bundle_hash_trits) + + # Check that we generated a secure bundle hash. + # https://github.com/iotaledger/iota.lib.py/issues/84 + if any(13 in part for part in normalize(bundle_hash)): + # Increment the legacy tag and try again. + tail_transaction = self.tail_transaction # type: ProposedTransaction + tail_transaction.increment_legacy_tag() + else: + break + + # Copy bundle hash to individual transactions. + for txn in self: + txn.bundle_hash = bundle_hash + + # Initialize signature/message fragment. + txn.signature_message_fragment = Fragment(txn.message or b'') + + def sign_inputs(self, key_generator): + # type: (KeyGenerator) -> None + """ + Sign inputs in a finalized bundle. + """ + if not self.hash: + raise RuntimeError('Cannot sign inputs until bundle is finalized.') + + # Use a counter for the loop so that we can skip ahead as we go. + i = 0 + while i < len(self): + txn = self[i] + + if txn.value < 0: + # In order to sign the input, we need to know the index of + # the private key used to generate it. + if txn.address.key_index is None: + raise with_context( + exc=ValueError( + 'Unable to sign input {input}; ``key_index`` is None ' + '(``exc.context`` has more info).'.format( + input=txn.address, + ), + ), + + context={ + 'transaction': txn, + }, + ) + + if txn.address.security_level is None: + raise with_context( + exc=ValueError( + 'Unable to sign input {input}; ``security_level`` is None ' + '(``exc.context`` has more info).'.format( + input=txn.address, + ), + ), + + context={ + 'transaction': txn, + }, + ) + + self.sign_input_at(i, key_generator.get_key_for(txn.address)) + + i += txn.address.security_level + else: + # No signature needed (nor even possible, in some cases); skip + # this transaction. + i += 1 + + def sign_input_at(self, start_index, private_key): + # type: (int, PrivateKey) -> None + """ + Signs the input at the specified index. + + :param start_index: + The index of the first input transaction. + + If necessary, the resulting signature will be split across + multiple transactions automatically (i.e., if an input has + ``security_level=2``, you still only need to call + :py:meth:`sign_input_at` once). + + :param private_key: + The private key that will be used to generate the signature. + + Important: be sure that the private key was generated using the + correct seed, or the resulting signature will be invalid! + """ + if not self.hash: + raise RuntimeError('Cannot sign inputs until bundle is finalized.') + + private_key.sign_input_transactions(self, start_index) + + def _create_input_transactions(self, addy): + # type: (Address) -> None + """ + Creates transactions for the specified input address. + """ + self._transactions.append(ProposedTransaction( + address=addy, + tag=self.tag, + + # Spend the entire address balance; if necessary, we will add a + # change transaction to the bundle. + value=-addy.balance, + )) - def _create_input_transactions(self, addy): - # type: (Address) -> None - """ - Creates transactions for the specified input address. - """ - self._transactions.append(ProposedTransaction( - address = addy, - tag = self.tag, - - # Spend the entire address balance; if necessary, we will add a - # change transaction to the bundle. - value = -addy.balance, - )) - - # Signatures require additional transactions to store, due to - # transaction length limit. - # Subtract 1 to account for the transaction we just added. - for _ in range(addy.security_level - 1): - self._transactions.append(ProposedTransaction( - address = addy, - tag = self.tag, - - # Note zero value; this is a meta transaction. - value = 0, - )) + # Signatures require additional transactions to store, due to + # transaction length limit. + # Subtract 1 to account for the transaction we just added. + for _ in range(addy.security_level - 1): + self._transactions.append(ProposedTransaction( + address=addy, + tag=self.tag, + + # Note zero value; this is a meta transaction. + value=0, + )) diff --git a/setup.py b/setup.py index dd2736d..13ae30b 100644 --- a/setup.py +++ b/setup.py @@ -26,6 +26,16 @@ long_description = f.read() +## +# Declare test dependencies separately, so that they can be installed +# either automatically (``python setup.py test``) or manually +# (``pip install -e .[test-runner]``). +tests_require = [ + 'mock; python_version < "3.0"', + 'nose', +] + + ## # Off we go! # noinspection SpellCheckingInspection @@ -33,7 +43,7 @@ name = 'PyOTA', description = 'IOTA API library for Python', url = 'https://github.com/iotaledger/iota.lib.py', - version = '2.0.4', + version = '2.0.5', long_description = long_description, @@ -67,15 +77,12 @@ extras_require = { 'ccurl': ['pyota-ccurl'], 'docs-builder': ['sphinx', 'sphinx_rtd_theme'], - 'test-runner': ['detox'], + 'test-runner': ['detox'] + tests_require, }, test_suite = 'test', test_loader = 'nose.loader:TestLoader', - tests_require = [ - 'mock; python_version < "3.0"', - 'nose', - ], + tests_require = tests_require, license = 'MIT', diff --git a/test/commands/core/were_addresses_spent_from_test.py b/test/commands/core/were_addresses_spent_from_test.py new file mode 100644 index 0000000..ba3053c --- /dev/null +++ b/test/commands/core/were_addresses_spent_from_test.py @@ -0,0 +1,175 @@ +# coding=utf-8 +from __future__ import absolute_import, division, print_function, \ + unicode_literals + +from unittest import TestCase + +import filters as f +from filters.test import BaseFilterTestCase + +from iota import Address, Iota, TryteString +from iota.adapter import MockAdapter +from iota.commands.core.were_addresses_spent_from import WereAddressesSpentFromCommand +from iota.filters import Trytes + + +class WereAddressesSpentFromRequestFilterTestCase(BaseFilterTestCase): + filter_type = WereAddressesSpentFromCommand(MockAdapter()).get_request_filter + skip_value_check = True + + # noinspection SpellCheckingInspection + def setUp(self): + super(WereAddressesSpentFromRequestFilterTestCase, self).setUp() + + # Define a few valid values that we can reuse across tests. + self.trytes1 = ( + 'TESTVALUE9DONTUSEINPRODUCTION99999EKJZZT' + 'SOGJOUNVEWLDPKGTGAOIZIPMGBLHC9LMQNHLGXGYX' + ) + + self.trytes2 = ( + 'TESTVALUE9DONTUSEINPRODUCTION99999FDCDTZ' + 'ZWLL9MYGUTLSYVSIFJ9NGALTRMCQVIIOVEQOITYTE' + ) + + def test_pass_happy_path(self): + """ + Typical invocation of ``wereAddressesSpentFrom``. + """ + request = { + # Raw trytes are extracted to match the IRI's JSON protocol. + 'addresses': [self.trytes1, self.trytes2], + } + + filter_ = self._filter(request) + + self.assertFilterPasses(filter_) + self.assertDictEqual(filter_.cleaned_data, request) + + def test_pass_compatible_types(self): + """ + The incoming request contains values that can be converted to the + expected types. + """ + request = { + 'addresses': [ + Address(self.trytes1), + bytearray(self.trytes2.encode('ascii')), + ], + } + + filter_ = self._filter(request) + + self.assertFilterPasses(filter_) + self.assertDictEqual( + filter_.cleaned_data, + + { + 'addresses': [self.trytes1, self.trytes2], + }, + ) + + def test_fail_empty(self): + """ + The incoming request is empty. + """ + self.assertFilterErrors( + {}, + + { + 'addresses': [f.FilterMapper.CODE_MISSING_KEY], + }, + ) + + def test_fail_unexpected_parameters(self): + """ + The incoming request contains unexpected parameters. + """ + self.assertFilterErrors( + { + 'addresses': [Address(self.trytes1)], + + # I've had a perfectly wonderful evening. + # But this wasn't it. + 'foo': 'bar', + }, + + { + 'foo': [f.FilterMapper.CODE_EXTRA_KEY], + }, + ) + + def test_fail_addresses_wrong_type(self): + """ + ``addresses`` is not an array. + """ + self.assertFilterErrors( + { + 'addresses': Address(self.trytes1), + }, + + { + 'addresses': [f.Type.CODE_WRONG_TYPE], + }, + ) + + def test_fail_addresses_empty(self): + """ + ``addresses`` is an array, but it's empty. + """ + self.assertFilterErrors( + { + 'addresses': [], + }, + + { + 'addresses': [f.Required.CODE_EMPTY], + }, + ) + + def test_fail_addresses_contents_invalid(self): + """ + ``addresses`` is an array, but it contains invalid values. + """ + self.assertFilterErrors( + { + 'addresses': [ + b'', + True, + None, + b'not valid trytes', + + # This is actually valid; I just added it to make sure the + # filter isn't cheating! + TryteString(self.trytes2), + + 2130706433, + b'9' * 82, + ], + }, + + { + 'addresses.0': [f.Required.CODE_EMPTY], + 'addresses.1': [f.Type.CODE_WRONG_TYPE], + 'addresses.2': [f.Required.CODE_EMPTY], + 'addresses.3': [Trytes.CODE_NOT_TRYTES], + 'addresses.5': [f.Type.CODE_WRONG_TYPE], + 'addresses.6': [Trytes.CODE_WRONG_FORMAT], + }, + ) + + +class WereAddressesSpentFromCommandTestCase(TestCase): + def setUp(self): + super(WereAddressesSpentFromCommandTestCase, self).setUp() + + self.adapter = MockAdapter() + + def test_wireup(self): + """ + Verify that the command is wired up correctly. + """ + self.assertIsInstance( + Iota(self.adapter).wereAddressesSpentFrom, + WereAddressesSpentFromCommand, + ) diff --git a/test/transaction/creation_test.py b/test/transaction/creation_test.py index 915ff27..f648e99 100644 --- a/test/transaction/creation_test.py +++ b/test/transaction/creation_test.py @@ -864,3 +864,20 @@ def test_sign_input_at_error_already_signed(self): with self.assertRaises(ValueError): self.bundle.sign_input_at(1, private_key) + + def test_create_tag_from_string(self): + """ + Check if string value of tag is converted into a Tag object + """ + + transaction = ProposedTransaction( + address= + Address( + b'TESTVALUE9DONTUSEINPRODUCTION99999QARFLF' + b'TDVATBVFTFCGEHLFJBMHPBOBOHFBSGAGWCM9PG9GX' + ), + tag="AAAZZZZ999", + value=42, + ) + + self.assertEqual(type(transaction.tag), type(Tag(b'')))