From db1bfef5cd3e6341e730e3c9b94aaff7b0291598 Mon Sep 17 00:00:00 2001 From: Johannes Innerbichler Date: Mon, 11 Dec 2017 09:46:32 +0100 Subject: [PATCH 1/8] added empty command class --- iota/commands/extended/is_reattachable.py | 24 +++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 iota/commands/extended/is_reattachable.py diff --git a/iota/commands/extended/is_reattachable.py b/iota/commands/extended/is_reattachable.py new file mode 100644 index 00000000..912b19fd --- /dev/null +++ b/iota/commands/extended/is_reattachable.py @@ -0,0 +1,24 @@ +# coding=utf-8 +from __future__ import absolute_import, division, print_function, unicode_literals + +from iota.commands import FilterCommand + +__all__ = [ + 'isReattachableCommand', +] + + +class isReattachableCommand(FilterCommand): + """ + Executes ``isReattachable`` extended API command. + """ + command = 'isReattachable' + + def get_request_filter(self): + pass + + def get_response_filter(self): + pass + + def _execute(self, request): + pass From a4b60065eb2d5265a07002843baf173651059cff Mon Sep 17 00:00:00 2001 From: Johannes Innerbichler Date: Mon, 11 Dec 2017 18:20:41 +0100 Subject: [PATCH 2/8] worked on first iteration of unit tests --- iota/api.py | 23 +++ iota/commands/extended/__init__.py | 1 + iota/commands/extended/is_reattachable.py | 40 +++- .../commands/extended/is_reattachable_test.py | 179 ++++++++++++++++++ 4 files changed, 238 insertions(+), 5 deletions(-) create mode 100644 test/commands/extended/is_reattachable_test.py diff --git a/iota/api.py b/iota/api.py index aeb6f831..dc1100a8 100644 --- a/iota/api.py +++ b/iota/api.py @@ -886,3 +886,26 @@ def send_trytes(self, trytes, depth, min_weight_magnitude=None): depth = depth, minWeightMagnitude = min_weight_magnitude, ) + + 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. + 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 + transaction and as such you should no longer replay the transaction. + + :param addresses: + List of addresses. + + :return: + Dict containing the following values:: + { + 'reattachable': List[bool], + } + """ + return extended.IsReattachableCommand(self.adapter)( + addresses=addresses + ) diff --git a/iota/commands/extended/__init__.py b/iota/commands/extended/__init__.py index f7a5bdf9..715a4a8a 100644 --- a/iota/commands/extended/__init__.py +++ b/iota/commands/extended/__init__.py @@ -22,3 +22,4 @@ from .replay_bundle import * from .send_transfer import * from .send_trytes import * +from .is_reattachable import * diff --git a/iota/commands/extended/is_reattachable.py b/iota/commands/extended/is_reattachable.py index 912b19fd..26ef582b 100644 --- a/iota/commands/extended/is_reattachable.py +++ b/iota/commands/extended/is_reattachable.py @@ -1,24 +1,54 @@ # coding=utf-8 from __future__ import absolute_import, division, print_function, unicode_literals -from iota.commands import FilterCommand +import filters as f +from iota import Address +from iota.commands import FilterCommand, RequestFilter, ResponseFilter +from iota.filters import Trytes __all__ = [ - 'isReattachableCommand', + 'IsReattachableCommand', ] -class isReattachableCommand(FilterCommand): +class IsReattachableCommand(FilterCommand): """ Executes ``isReattachable`` extended API command. """ command = 'isReattachable' def get_request_filter(self): - pass + return IsReattachableRequestFilter() def get_response_filter(self): - pass + return IsReattachableResponseFilter() def _execute(self, request): pass + + +class IsReattachableRequestFilter(RequestFilter): + def __init__(self): + super(IsReattachableRequestFilter, self).__init__( + { + 'addresses': ( + f.Required + | f.Array + | f.FilterRepeater( + f.Required + | Trytes(result_type=Address) + | f.Unicode(encoding='ascii', normalize=False) + ) + ) + } + ) + + +class IsReattachableResponseFilter(ResponseFilter): + def __init__(self): + super(IsReattachableResponseFilter, self).__init__({ + 'reattachable': ( + f.Required + | f.Array + | f.FilterRepeater(f.Type(bool))) + }) diff --git a/test/commands/extended/is_reattachable_test.py b/test/commands/extended/is_reattachable_test.py new file mode 100644 index 00000000..a12bf56d --- /dev/null +++ b/test/commands/extended/is_reattachable_test.py @@ -0,0 +1,179 @@ +# 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, TryteString, Iota +from iota.adapter import MockAdapter +from iota.commands.extended.is_reattachable import IsReattachableCommand + + +class IsReattachableRequestFilterTestCase(BaseFilterTestCase): + filter_type = IsReattachableCommand(MockAdapter()).get_request_filter + skip_value_check = True + + # noinspection SpellCheckingInspection + def setUp(self): + super(IsReattachableRequestFilterTestCase, self).setUp() + + # Define a few valid values that we can reuse across tests. + self.addresses_1 = ( + 'TESTVALUE9DONTUSEINPRODUCTION99999EKJZZT' + 'SOGJOUNVEWLDPKGTGAOIZIPMGBLHC9LMQNHLGXGYX' + ) + + self.addresses_2 = ( + 'TESTVALUE9DONTUSEINPRODUCTION99999FDCDTZ' + 'ZWLL9MYGUTLSYVSIFJ9NGALTRMCQVIIOVEQOITYTE' + ) + + def test_pass_happy_path(self): + """ + Filter for list of valid string addresses + """ + + request = { + # Raw trytes are extracted to match the IRI's JSON protocol. + 'addresses': [self.addresses_1, self.addresses_2], + } + + 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.addresses_1), + bytearray(self.addresses_2.encode('ascii')), + ], + } + + filter_ = self._filter(request) + + self.assertFilterPasses(filter_) + self.assertDictEqual( + filter_.cleaned_data, + { + 'addresses': [self.addresses_1, self.addresses_2], + }, + ) + + def test_pass_incompatible_types(self): + """ + The incoming request contains values that can NOT be converted to the + expected types. + """ + request = { + 'addresses': [ + 1234234, + False + ], + } + + self.assertFilterErrors( + request, + { + 'addresses.0': [f.Type.CODE_WRONG_TYPE], + 'addresses.1': [f.Type.CODE_WRONG_TYPE] + }, + ) + + def test_fail_empty(self): + """ + The incoming request is empty. + """ + self.assertFilterErrors( + {}, + { + 'addresses': [f.FilterMapper.CODE_MISSING_KEY], + }, + ) + + +# noinspection SpellCheckingInspection +class IsReattachableResponseFilterTestCase(BaseFilterTestCase): + filter_type = IsReattachableCommand(MockAdapter()).get_response_filter + skip_value_check = True + + # noinspection SpellCheckingInspection + def setUp(self): + super(IsReattachableResponseFilterTestCase, self).setUp() + + # Define a few valid values that we can reuse across tests. + self.addresses_1 = ( + 'TESTVALUE9DONTUSEINPRODUCTION99999EKJZZT' + 'SOGJOUNVEWLDPKGTGAOIZIPMGBLHC9LMQNHLGXGYX' + ) + + def test_pass_happy_path(self): + """ + Typical ``IsReattachable`` request. + """ + response = { + 'reattachable': [True, False] + } + + filter_ = self._filter(response) + + self.assertFilterPasses(filter_) + self.assertDictEqual(filter_.cleaned_data, response) + + def test_fail_empty(self): + """ + The incoming response is empty. + """ + self.assertFilterErrors( + {}, + { + 'reattachable': [f.Required.CODE_EMPTY], + }, + ) + + def test_pass_incompatible_types(self): + """ + The response contains values that can NOT be converted to the + expected types. + """ + request = { + 'reattachable': [ + 1234234, + b'', + 'test' + ], + } + + self.assertFilterErrors( + request, + { + 'reattachable.0': [f.Type.CODE_WRONG_TYPE], + 'reattachable.1': [f.Type.CODE_WRONG_TYPE], + 'reattachable.2': [f.Type.CODE_WRONG_TYPE] + }, + ) + + +class IsReattachableCommandTestCase(TestCase): + def setUp(self): + super(IsReattachableCommandTestCase, self).setUp() + + self.adapter = MockAdapter() + + def test_wireup(self): + """ + Verify that the command is wired up correctly. + """ + self.assertIsInstance( + Iota(self.adapter).isReattachable, + IsReattachableCommand, + ) + From 573c4f47db5e7b897e65f856d1eb546b00380b85 Mon Sep 17 00:00:00 2001 From: Johannes Innerbichler Date: Mon, 11 Dec 2017 18:57:48 +0100 Subject: [PATCH 3/8] worked on execution of isReattachable --- iota/api.py | 1 + iota/commands/extended/is_reattachable.py | 21 ++++++++++++- .../commands/extended/is_reattachable_test.py | 30 +++++++++++++------ 3 files changed, 42 insertions(+), 10 deletions(-) diff --git a/iota/api.py b/iota/api.py index dc1100a8..4c8d56a1 100644 --- a/iota/api.py +++ b/iota/api.py @@ -904,6 +904,7 @@ def is_reattachable(self, addresses): Dict containing the following values:: { 'reattachable': List[bool], + Always a list, even if only one address was queried. } """ return extended.IsReattachableCommand(self.adapter)( diff --git a/iota/commands/extended/is_reattachable.py b/iota/commands/extended/is_reattachable.py index 26ef582b..74d0dfc9 100644 --- a/iota/commands/extended/is_reattachable.py +++ b/iota/commands/extended/is_reattachable.py @@ -1,9 +1,14 @@ # coding=utf-8 from __future__ import absolute_import, division, print_function, unicode_literals +from typing import List + import filters as f + from iota import Address from iota.commands import FilterCommand, RequestFilter, ResponseFilter +from iota.commands.extended import GetLatestInclusionCommand +from iota.commands.extended.utils import find_transaction_objects from iota.filters import Trytes __all__ = [ @@ -24,7 +29,21 @@ def get_response_filter(self): return IsReattachableResponseFilter() def _execute(self, request): - pass + addresses = request['addresses'] # type: List[Address] + + # fetch full transaction objects + transactions = find_transaction_objects(adapter=self.adapter, **{'addresses': addresses}) + + # map and filter transactions, which have zero value + transaction_map = {t.address: t.hash for t in transactions if t.value > 0} + + # fetch inclusion states + inclusion_states = GetLatestInclusionCommand(adapter=self.adapter)(hashes=transaction_map.values()) + + # map inclusion states to addresses + return { + 'reattachable': [not inclusion_states[transaction_map[address]] for address in addresses] + } class IsReattachableRequestFilter(RequestFilter): diff --git a/test/commands/extended/is_reattachable_test.py b/test/commands/extended/is_reattachable_test.py index a12bf56d..dc857ded 100644 --- a/test/commands/extended/is_reattachable_test.py +++ b/test/commands/extended/is_reattachable_test.py @@ -6,8 +6,9 @@ import filters as f from filters.test import BaseFilterTestCase +from six import text_type -from iota import Address, TryteString, Iota +from iota import Address, Iota from iota.adapter import MockAdapter from iota.commands.extended.is_reattachable import IsReattachableCommand @@ -21,12 +22,12 @@ def setUp(self): super(IsReattachableRequestFilterTestCase, self).setUp() # Define a few valid values that we can reuse across tests. - self.addresses_1 = ( + self.address_1 = ( 'TESTVALUE9DONTUSEINPRODUCTION99999EKJZZT' 'SOGJOUNVEWLDPKGTGAOIZIPMGBLHC9LMQNHLGXGYX' ) - self.addresses_2 = ( + self.address_2 = ( 'TESTVALUE9DONTUSEINPRODUCTION99999FDCDTZ' 'ZWLL9MYGUTLSYVSIFJ9NGALTRMCQVIIOVEQOITYTE' ) @@ -38,13 +39,25 @@ def test_pass_happy_path(self): request = { # Raw trytes are extracted to match the IRI's JSON protocol. - 'addresses': [self.addresses_1, self.addresses_2], + 'addresses': [ + self.address_1, + Address(self.address_2) + ], } filter_ = self._filter(request) self.assertFilterPasses(filter_) - self.assertDictEqual(filter_.cleaned_data, request) + + self.assertDictEqual( + filter_.cleaned_data, + { + 'addresses': [ + text_type(Address(self.address_1)), + text_type(Address(self.address_2)) + ], + }, + ) def test_pass_compatible_types(self): """ @@ -53,8 +66,8 @@ def test_pass_compatible_types(self): """ request = { 'addresses': [ - Address(self.addresses_1), - bytearray(self.addresses_2.encode('ascii')), + Address(self.address_1), + bytearray(self.address_2.encode('ascii')), ], } @@ -64,7 +77,7 @@ def test_pass_compatible_types(self): self.assertDictEqual( filter_.cleaned_data, { - 'addresses': [self.addresses_1, self.addresses_2], + 'addresses': [self.address_1, self.address_2], }, ) @@ -176,4 +189,3 @@ def test_wireup(self): Iota(self.adapter).isReattachable, IsReattachableCommand, ) - From 14c6854ea5b547e99e6418afd6d5b26e6bed79d6 Mon Sep 17 00:00:00 2001 From: Johannes Innerbichler Date: Tue, 12 Dec 2017 00:32:51 +0100 Subject: [PATCH 4/8] finished first version of execution --- iota/commands/extended/is_reattachable.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/iota/commands/extended/is_reattachable.py b/iota/commands/extended/is_reattachable.py index 74d0dfc9..7a2efcc3 100644 --- a/iota/commands/extended/is_reattachable.py +++ b/iota/commands/extended/is_reattachable.py @@ -1,6 +1,6 @@ # coding=utf-8 from __future__ import absolute_import, division, print_function, unicode_literals - +from itertools import groupby from typing import List import filters as f @@ -34,13 +34,16 @@ def _execute(self, request): # fetch full transaction objects transactions = find_transaction_objects(adapter=self.adapter, **{'addresses': addresses}) - # map and filter transactions, which have zero value + # map and filter transactions, which have zero value. + # If multiple transactions for the same address are returned the one with the + # highest attachment_timestamp is selected + transactions = sorted(transactions, key=lambda t: t.attachment_timestamp) transaction_map = {t.address: t.hash for t in transactions if t.value > 0} # fetch inclusion states - inclusion_states = GetLatestInclusionCommand(adapter=self.adapter)(hashes=transaction_map.values()) + inclusion_states = GetLatestInclusionCommand(adapter=self.adapter)(hashes=list(transaction_map.values())) + inclusion_states = inclusion_states['states'] - # map inclusion states to addresses return { 'reattachable': [not inclusion_states[transaction_map[address]] for address in addresses] } From e05083f9c2d24573b17ccbb4a440d805b58c1cfb Mon Sep 17 00:00:00 2001 From: Johannes Innerbichler Date: Wed, 13 Dec 2017 16:26:17 +0100 Subject: [PATCH 5/8] removed import --- iota/commands/extended/is_reattachable.py | 1 - 1 file changed, 1 deletion(-) diff --git a/iota/commands/extended/is_reattachable.py b/iota/commands/extended/is_reattachable.py index 7a2efcc3..b3085678 100644 --- a/iota/commands/extended/is_reattachable.py +++ b/iota/commands/extended/is_reattachable.py @@ -1,6 +1,5 @@ # coding=utf-8 from __future__ import absolute_import, division, print_function, unicode_literals -from itertools import groupby from typing import List import filters as f From 35b632594eeb8c5fcb1e03c68f4236f62387fecb Mon Sep 17 00:00:00 2001 From: Johannes Innerbichler Date: Mon, 8 Jan 2018 16:08:18 +0100 Subject: [PATCH 6/8] changed order of imports --- iota/commands/extended/__init__.py | 2 +- test/commands/extended/is_reattachable_test.py | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/iota/commands/extended/__init__.py b/iota/commands/extended/__init__.py index 715a4a8a..af596c9e 100644 --- a/iota/commands/extended/__init__.py +++ b/iota/commands/extended/__init__.py @@ -18,8 +18,8 @@ from .get_latest_inclusion import * from .get_new_addresses import * from .get_transfers import * +from .is_reattachable import * from .prepare_transfer import * from .replay_bundle import * from .send_transfer import * from .send_trytes import * -from .is_reattachable import * diff --git a/test/commands/extended/is_reattachable_test.py b/test/commands/extended/is_reattachable_test.py index dc857ded..11a6a0c9 100644 --- a/test/commands/extended/is_reattachable_test.py +++ b/test/commands/extended/is_reattachable_test.py @@ -59,6 +59,24 @@ def test_pass_happy_path(self): }, ) + def test_pass_single_address(self): + """ + The incoming request contains a single address + """ + request = { + 'addresses': Address(self.address_1) + } + + filter_ = self._filter(request) + + self.assertFilterPasses(filter_) + self.assertDictEqual( + filter_.cleaned_data, + { + 'addresses': text_type(Address(self.address_1)) + }, + ) + def test_pass_compatible_types(self): """ The incoming request contains values that can be converted to the From 0b757c8f5ab0cb5755b21bc64cbe6000cd0e64a7 Mon Sep 17 00:00:00 2001 From: Johannes Innerbichler Date: Mon, 8 Jan 2018 16:11:30 +0100 Subject: [PATCH 7/8] removed test for single address for now --- .../commands/extended/is_reattachable_test.py | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/test/commands/extended/is_reattachable_test.py b/test/commands/extended/is_reattachable_test.py index 11a6a0c9..06490551 100644 --- a/test/commands/extended/is_reattachable_test.py +++ b/test/commands/extended/is_reattachable_test.py @@ -59,23 +59,23 @@ def test_pass_happy_path(self): }, ) - def test_pass_single_address(self): - """ - The incoming request contains a single address - """ - request = { - 'addresses': Address(self.address_1) - } - - filter_ = self._filter(request) - - self.assertFilterPasses(filter_) - self.assertDictEqual( - filter_.cleaned_data, - { - 'addresses': text_type(Address(self.address_1)) - }, - ) + # def test_pass_single_address(self): + # """ + # The incoming request contains a single address + # """ + # request = { + # 'addresses': Address(self.address_1) + # } + # + # filter_ = self._filter(request) + # + # self.assertFilterPasses(filter_) + # self.assertDictEqual( + # filter_.cleaned_data, + # { + # 'addresses': text_type(Address(self.address_1)) + # }, + # ) def test_pass_compatible_types(self): """ From 9311bd152f19f8aca2b176b5b1b4652a26357399 Mon Sep 17 00:00:00 2001 From: Johannes Innerbichler Date: Sun, 14 Jan 2018 00:53:32 +0100 Subject: [PATCH 8/8] added test for single address --- .../commands/extended/is_reattachable_test.py | 33 +++++++++---------- 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/test/commands/extended/is_reattachable_test.py b/test/commands/extended/is_reattachable_test.py index 06490551..edef5fd5 100644 --- a/test/commands/extended/is_reattachable_test.py +++ b/test/commands/extended/is_reattachable_test.py @@ -59,24 +59,6 @@ def test_pass_happy_path(self): }, ) - # def test_pass_single_address(self): - # """ - # The incoming request contains a single address - # """ - # request = { - # 'addresses': Address(self.address_1) - # } - # - # filter_ = self._filter(request) - # - # self.assertFilterPasses(filter_) - # self.assertDictEqual( - # filter_.cleaned_data, - # { - # 'addresses': text_type(Address(self.address_1)) - # }, - # ) - def test_pass_compatible_types(self): """ The incoming request contains values that can be converted to the @@ -130,6 +112,21 @@ def test_fail_empty(self): }, ) + def test_fail_single_address(self): + """ + The incoming request contains a single address + """ + request = { + 'addresses': Address(self.address_1) + } + + self.assertFilterErrors( + request, + { + 'addresses': [f.Type.CODE_WRONG_TYPE], + } + ) + # noinspection SpellCheckingInspection class IsReattachableResponseFilterTestCase(BaseFilterTestCase):