Skip to content
This repository was archived by the owner on Jan 13, 2023. It is now read-only.

Commit 702760a

Browse files
authored
Merge pull request #108 from jinnerbichler/38-is_reattachable
Implementation of IsReattachableCommand
2 parents df4714e + 9311bd1 commit 702760a

File tree

4 files changed

+306
-0
lines changed

4 files changed

+306
-0
lines changed

iota/api.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -943,3 +943,27 @@ def send_trytes(self, trytes, depth, min_weight_magnitude=None):
943943
depth = depth,
944944
minWeightMagnitude = min_weight_magnitude,
945945
)
946+
947+
def is_reattachable(self, addresses):
948+
# type: (Iterable[Address]) -> dict
949+
"""
950+
This API function helps you to determine whether you should replay a
951+
transaction or make a completely new transaction with a different seed.
952+
What this function does, is it takes one or more input addresses (i.e. from spent transactions)
953+
as input and then checks whether any transactions with a value transferred are confirmed.
954+
If yes, it means that this input address has already been successfully used in a different
955+
transaction and as such you should no longer replay the transaction.
956+
957+
:param addresses:
958+
List of addresses.
959+
960+
:return:
961+
Dict containing the following values::
962+
{
963+
'reattachable': List[bool],
964+
Always a list, even if only one address was queried.
965+
}
966+
"""
967+
return extended.IsReattachableCommand(self.adapter)(
968+
addresses=addresses
969+
)

iota/commands/extended/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from .get_latest_inclusion import *
1919
from .get_new_addresses import *
2020
from .get_transfers import *
21+
from .is_reattachable import *
2122
from .prepare_transfer import *
2223
from .promote_transaction import *
2324
from .replay_bundle import *
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# coding=utf-8
2+
from __future__ import absolute_import, division, print_function, unicode_literals
3+
from typing import List
4+
5+
import filters as f
6+
7+
from iota import Address
8+
from iota.commands import FilterCommand, RequestFilter, ResponseFilter
9+
from iota.commands.extended import GetLatestInclusionCommand
10+
from iota.commands.extended.utils import find_transaction_objects
11+
from iota.filters import Trytes
12+
13+
__all__ = [
14+
'IsReattachableCommand',
15+
]
16+
17+
18+
class IsReattachableCommand(FilterCommand):
19+
"""
20+
Executes ``isReattachable`` extended API command.
21+
"""
22+
command = 'isReattachable'
23+
24+
def get_request_filter(self):
25+
return IsReattachableRequestFilter()
26+
27+
def get_response_filter(self):
28+
return IsReattachableResponseFilter()
29+
30+
def _execute(self, request):
31+
addresses = request['addresses'] # type: List[Address]
32+
33+
# fetch full transaction objects
34+
transactions = find_transaction_objects(adapter=self.adapter, **{'addresses': addresses})
35+
36+
# map and filter transactions, which have zero value.
37+
# If multiple transactions for the same address are returned the one with the
38+
# highest attachment_timestamp is selected
39+
transactions = sorted(transactions, key=lambda t: t.attachment_timestamp)
40+
transaction_map = {t.address: t.hash for t in transactions if t.value > 0}
41+
42+
# fetch inclusion states
43+
inclusion_states = GetLatestInclusionCommand(adapter=self.adapter)(hashes=list(transaction_map.values()))
44+
inclusion_states = inclusion_states['states']
45+
46+
return {
47+
'reattachable': [not inclusion_states[transaction_map[address]] for address in addresses]
48+
}
49+
50+
51+
class IsReattachableRequestFilter(RequestFilter):
52+
def __init__(self):
53+
super(IsReattachableRequestFilter, self).__init__(
54+
{
55+
'addresses': (
56+
f.Required
57+
| f.Array
58+
| f.FilterRepeater(
59+
f.Required
60+
| Trytes(result_type=Address)
61+
| f.Unicode(encoding='ascii', normalize=False)
62+
)
63+
)
64+
}
65+
)
66+
67+
68+
class IsReattachableResponseFilter(ResponseFilter):
69+
def __init__(self):
70+
super(IsReattachableResponseFilter, self).__init__({
71+
'reattachable': (
72+
f.Required
73+
| f.Array
74+
| f.FilterRepeater(f.Type(bool)))
75+
})
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
# coding=utf-8
2+
from __future__ import absolute_import, division, print_function, \
3+
unicode_literals
4+
5+
from unittest import TestCase
6+
7+
import filters as f
8+
from filters.test import BaseFilterTestCase
9+
from six import text_type
10+
11+
from iota import Address, Iota
12+
from iota.adapter import MockAdapter
13+
from iota.commands.extended.is_reattachable import IsReattachableCommand
14+
15+
16+
class IsReattachableRequestFilterTestCase(BaseFilterTestCase):
17+
filter_type = IsReattachableCommand(MockAdapter()).get_request_filter
18+
skip_value_check = True
19+
20+
# noinspection SpellCheckingInspection
21+
def setUp(self):
22+
super(IsReattachableRequestFilterTestCase, self).setUp()
23+
24+
# Define a few valid values that we can reuse across tests.
25+
self.address_1 = (
26+
'TESTVALUE9DONTUSEINPRODUCTION99999EKJZZT'
27+
'SOGJOUNVEWLDPKGTGAOIZIPMGBLHC9LMQNHLGXGYX'
28+
)
29+
30+
self.address_2 = (
31+
'TESTVALUE9DONTUSEINPRODUCTION99999FDCDTZ'
32+
'ZWLL9MYGUTLSYVSIFJ9NGALTRMCQVIIOVEQOITYTE'
33+
)
34+
35+
def test_pass_happy_path(self):
36+
"""
37+
Filter for list of valid string addresses
38+
"""
39+
40+
request = {
41+
# Raw trytes are extracted to match the IRI's JSON protocol.
42+
'addresses': [
43+
self.address_1,
44+
Address(self.address_2)
45+
],
46+
}
47+
48+
filter_ = self._filter(request)
49+
50+
self.assertFilterPasses(filter_)
51+
52+
self.assertDictEqual(
53+
filter_.cleaned_data,
54+
{
55+
'addresses': [
56+
text_type(Address(self.address_1)),
57+
text_type(Address(self.address_2))
58+
],
59+
},
60+
)
61+
62+
def test_pass_compatible_types(self):
63+
"""
64+
The incoming request contains values that can be converted to the
65+
expected types.
66+
"""
67+
request = {
68+
'addresses': [
69+
Address(self.address_1),
70+
bytearray(self.address_2.encode('ascii')),
71+
],
72+
}
73+
74+
filter_ = self._filter(request)
75+
76+
self.assertFilterPasses(filter_)
77+
self.assertDictEqual(
78+
filter_.cleaned_data,
79+
{
80+
'addresses': [self.address_1, self.address_2],
81+
},
82+
)
83+
84+
def test_pass_incompatible_types(self):
85+
"""
86+
The incoming request contains values that can NOT be converted to the
87+
expected types.
88+
"""
89+
request = {
90+
'addresses': [
91+
1234234,
92+
False
93+
],
94+
}
95+
96+
self.assertFilterErrors(
97+
request,
98+
{
99+
'addresses.0': [f.Type.CODE_WRONG_TYPE],
100+
'addresses.1': [f.Type.CODE_WRONG_TYPE]
101+
},
102+
)
103+
104+
def test_fail_empty(self):
105+
"""
106+
The incoming request is empty.
107+
"""
108+
self.assertFilterErrors(
109+
{},
110+
{
111+
'addresses': [f.FilterMapper.CODE_MISSING_KEY],
112+
},
113+
)
114+
115+
def test_fail_single_address(self):
116+
"""
117+
The incoming request contains a single address
118+
"""
119+
request = {
120+
'addresses': Address(self.address_1)
121+
}
122+
123+
self.assertFilterErrors(
124+
request,
125+
{
126+
'addresses': [f.Type.CODE_WRONG_TYPE],
127+
}
128+
)
129+
130+
131+
# noinspection SpellCheckingInspection
132+
class IsReattachableResponseFilterTestCase(BaseFilterTestCase):
133+
filter_type = IsReattachableCommand(MockAdapter()).get_response_filter
134+
skip_value_check = True
135+
136+
# noinspection SpellCheckingInspection
137+
def setUp(self):
138+
super(IsReattachableResponseFilterTestCase, self).setUp()
139+
140+
# Define a few valid values that we can reuse across tests.
141+
self.addresses_1 = (
142+
'TESTVALUE9DONTUSEINPRODUCTION99999EKJZZT'
143+
'SOGJOUNVEWLDPKGTGAOIZIPMGBLHC9LMQNHLGXGYX'
144+
)
145+
146+
def test_pass_happy_path(self):
147+
"""
148+
Typical ``IsReattachable`` request.
149+
"""
150+
response = {
151+
'reattachable': [True, False]
152+
}
153+
154+
filter_ = self._filter(response)
155+
156+
self.assertFilterPasses(filter_)
157+
self.assertDictEqual(filter_.cleaned_data, response)
158+
159+
def test_fail_empty(self):
160+
"""
161+
The incoming response is empty.
162+
"""
163+
self.assertFilterErrors(
164+
{},
165+
{
166+
'reattachable': [f.Required.CODE_EMPTY],
167+
},
168+
)
169+
170+
def test_pass_incompatible_types(self):
171+
"""
172+
The response contains values that can NOT be converted to the
173+
expected types.
174+
"""
175+
request = {
176+
'reattachable': [
177+
1234234,
178+
b'',
179+
'test'
180+
],
181+
}
182+
183+
self.assertFilterErrors(
184+
request,
185+
{
186+
'reattachable.0': [f.Type.CODE_WRONG_TYPE],
187+
'reattachable.1': [f.Type.CODE_WRONG_TYPE],
188+
'reattachable.2': [f.Type.CODE_WRONG_TYPE]
189+
},
190+
)
191+
192+
193+
class IsReattachableCommandTestCase(TestCase):
194+
def setUp(self):
195+
super(IsReattachableCommandTestCase, self).setUp()
196+
197+
self.adapter = MockAdapter()
198+
199+
def test_wireup(self):
200+
"""
201+
Verify that the command is wired up correctly.
202+
"""
203+
self.assertIsInstance(
204+
Iota(self.adapter).isReattachable,
205+
IsReattachableCommand,
206+
)

0 commit comments

Comments
 (0)