From ab729af539e6f3288c1d737e8c6d24614893ec71 Mon Sep 17 00:00:00 2001 From: scottbelden Date: Tue, 2 Jan 2018 09:29:21 -0500 Subject: [PATCH] add promote_transaction_api --- iota/api.py | 27 ++ .../core/get_transactions_to_approve.py | 18 + iota/commands/extended/__init__.py | 1 + iota/commands/extended/promote_transaction.py | 68 ++++ iota/commands/extended/send_transfer.py | 7 +- iota/commands/extended/send_trytes.py | 14 +- .../core/get_transactions_to_approve_test.py | 60 ++- .../extended/promote_transaction_test.py | 371 ++++++++++++++++++ test/commands/extended/send_transfer_test.py | 45 ++- test/commands/extended/send_trytes_test.py | 6 +- 10 files changed, 610 insertions(+), 7 deletions(-) create mode 100644 iota/commands/extended/promote_transaction.py create mode 100644 test/commands/extended/promote_transaction_test.py diff --git a/iota/api.py b/iota/api.py index b54b50d8..05c06ecd 100644 --- a/iota/api.py +++ b/iota/api.py @@ -775,6 +775,33 @@ def prepare_transfer(self, transfers, inputs=None, change_address=None): changeAddress = change_address, ) + def promote_transaction( + self, + transaction, + depth, + min_weight_magnitude = None, + ): + # type: (TransactionHash, int, Optional[int]) -> dict + """ + Promotes a transaction by adding spam on top of it. + + :return: + Dict containing the following values:: + + { + 'bundle': Bundle, + The newly-published bundle. + } + """ + if min_weight_magnitude is None: + min_weight_magnitude = self.default_min_weight_magnitude + + return extended.PromoteTransactionCommand(self.adapter)( + transaction = transaction, + depth = depth, + minWeightMagnitude = min_weight_magnitude, + ) + def replay_bundle( self, transaction, diff --git a/iota/commands/core/get_transactions_to_approve.py b/iota/commands/core/get_transactions_to_approve.py index 66d4ffc1..7ab0b86c 100644 --- a/iota/commands/core/get_transactions_to_approve.py +++ b/iota/commands/core/get_transactions_to_approve.py @@ -32,8 +32,26 @@ class GetTransactionsToApproveRequestFilter(RequestFilter): def __init__(self): super(GetTransactionsToApproveRequestFilter, self).__init__({ 'depth': f.Required | f.Type(int) | f.Min(1), + + 'reference': Trytes(result_type=TransactionHash), + }, + + allow_missing_keys = { + 'reference', }) + def _apply(self, value): + value = super(GetTransactionsToApproveRequestFilter, self)._apply(value) # type: dict + + if self._has_errors: + return value + + # Remove reference if null. + if value['reference'] is None: + del value['reference'] + + return value + class GetTransactionsToApproveResponseFilter(ResponseFilter): def __init__(self): diff --git a/iota/commands/extended/__init__.py b/iota/commands/extended/__init__.py index f7a5bdf9..5b6f95b7 100644 --- a/iota/commands/extended/__init__.py +++ b/iota/commands/extended/__init__.py @@ -19,6 +19,7 @@ from .get_new_addresses import * from .get_transfers import * from .prepare_transfer import * +from .promote_transaction import * from .replay_bundle import * from .send_transfer import * from .send_trytes import * diff --git a/iota/commands/extended/promote_transaction.py b/iota/commands/extended/promote_transaction.py new file mode 100644 index 00000000..ccc6e3e9 --- /dev/null +++ b/iota/commands/extended/promote_transaction.py @@ -0,0 +1,68 @@ +# coding=utf-8 +from __future__ import absolute_import, division, print_function, \ + unicode_literals + +import filters as f +from iota import ( + Bundle, TransactionHash, Address, ProposedTransaction, BadApiResponse, +) +from iota.commands import FilterCommand, RequestFilter +from iota.commands.core.check_consistency import CheckConsistencyCommand +from iota.commands.extended.send_transfer import SendTransferCommand +from iota.filters import Trytes + +__all__ = [ + 'PromoteTransactionCommand', +] + + +class PromoteTransactionCommand(FilterCommand): + """ + Executes ``promoteTransaction`` extended API command. + + See :py:meth:`iota.api.Iota.promote_transaction` for more information. + """ + command = 'promoteTransaction' + + def get_request_filter(self): + return PromoteTransactionRequestFilter() + + def get_response_filter(self): + pass + + def _execute(self, request): + depth = request['depth'] # type: int + min_weight_magnitude = request['minWeightMagnitude'] # type: int + transaction = request['transaction'] # type: TransactionHash + + cc_response = CheckConsistencyCommand(self.adapter)(tails=[transaction]) + if cc_response['state'] is False: + raise BadApiResponse( + 'Transaction {transaction} is not promotable. ' + 'You should reattach first.'.format(transaction=transaction) + ) + + spam_transfer = ProposedTransaction( + address=Address(b''), + value=0, + ) + + return SendTransferCommand(self.adapter)( + seed=spam_transfer.address, + depth=depth, + transfers=[spam_transfer], + minWeightMagnitude=min_weight_magnitude, + reference=transaction, + ) + + +class PromoteTransactionRequestFilter(RequestFilter): + def __init__(self): + super(PromoteTransactionRequestFilter, self).__init__({ + 'depth': f.Required | f.Type(int) | f.Min(1), + 'transaction': f.Required | Trytes(result_type=TransactionHash), + + # Loosely-validated; testnet nodes require a different value than + # mainnet. + 'minWeightMagnitude': f.Required | f.Type(int) | f.Min(1), + }) diff --git a/iota/commands/extended/send_transfer.py b/iota/commands/extended/send_transfer.py index 4eecab28..99a9f39a 100644 --- a/iota/commands/extended/send_transfer.py +++ b/iota/commands/extended/send_transfer.py @@ -5,7 +5,7 @@ from typing import List, Optional import filters as f -from iota import Address, Bundle, ProposedTransaction +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 @@ -38,6 +38,7 @@ def _execute(self, request): min_weight_magnitude = request['minWeightMagnitude'] # type: int seed = request['seed'] # type: Seed transfers = request['transfers'] # type: List[ProposedTransaction] + reference = request['reference'] # type: Optional[TransactionHash] pt_response = PrepareTransferCommand(self.adapter)( changeAddress = change_address, @@ -50,6 +51,7 @@ def _execute(self, request): depth = depth, minWeightMagnitude = min_weight_magnitude, trytes = pt_response['trytes'], + reference = reference, ) return { @@ -82,10 +84,13 @@ def __init__(self): # Note that ``inputs`` is allowed to be an empty array. 'inputs': f.Array | f.FilterRepeater(f.Required | Trytes(result_type=Address)), + + 'reference': Trytes(result_type=TransactionHash), }, allow_missing_keys = { 'changeAddress', 'inputs', + 'reference', }, ) diff --git a/iota/commands/extended/send_trytes.py b/iota/commands/extended/send_trytes.py index 4578b88f..b30f4d48 100644 --- a/iota/commands/extended/send_trytes.py +++ b/iota/commands/extended/send_trytes.py @@ -5,7 +5,7 @@ from typing import List import filters as f -from iota import TransactionTrytes, TryteString +from iota import TransactionTrytes, TryteString, TransactionHash from iota.commands import FilterCommand, RequestFilter from iota.commands.core.attach_to_tangle import AttachToTangleCommand from iota.commands.core.get_transactions_to_approve import \ @@ -36,10 +36,14 @@ def _execute(self, request): depth = request['depth'] # type: int min_weight_magnitude = request['minWeightMagnitude'] # type: int trytes = request['trytes'] # type: List[TryteString] + reference = request['reference'] # type: Optional[TransactionHash] # Call ``getTransactionsToApprove`` to locate trunk and branch # transactions so that we can attach the bundle to the Tangle. - gta_response = GetTransactionsToApproveCommand(self.adapter)(depth=depth) + gta_response = GetTransactionsToApproveCommand(self.adapter)( + depth=depth, + reference=reference, + ) att_response = AttachToTangleCommand(self.adapter)( branchTransaction = gta_response.get('branchTransaction'), @@ -72,4 +76,10 @@ def __init__(self): # Loosely-validated; testnet nodes require a different value than # mainnet. 'minWeightMagnitude': f.Required | f.Type(int) | f.Min(1), + + 'reference': Trytes(result_type=TransactionHash), + }, + + allow_missing_keys = { + 'reference', }) diff --git a/test/commands/core/get_transactions_to_approve_test.py b/test/commands/core/get_transactions_to_approve_test.py index e3c07182..5875fdcf 100644 --- a/test/commands/core/get_transactions_to_approve_test.py +++ b/test/commands/core/get_transactions_to_approve_test.py @@ -10,6 +10,7 @@ from iota.adapter import MockAdapter from iota.commands.core.get_transactions_to_approve import \ GetTransactionsToApproveCommand +from iota.filters import Trytes class GetTransactionsToApproveRequestFilterTestCase(BaseFilterTestCase): @@ -17,12 +18,35 @@ class GetTransactionsToApproveRequestFilterTestCase(BaseFilterTestCase): GetTransactionsToApproveCommand(MockAdapter()).get_request_filter skip_value_check = True - def test_pass_happy_path(self): + def setUp(self): + super(GetTransactionsToApproveRequestFilterTestCase, self).setUp() + + # Define some tryte sequences that we can reuse between tests. + self.trytes1 = ( + b'TESTVALUEONE9DONTUSEINPRODUCTION99999JBW' + b'GEC99GBXFFBCHAEJHLC9DX9EEPAI9ICVCKBX9FFII' + ) + + def test_pass_happy_path_without_reference(self): + """ + Request is valid without reference. + """ + request = { + 'depth': 100, + } + + filter_ = self._filter(request) + + self.assertFilterPasses(filter_) + self.assertDictEqual(filter_.cleaned_data, request) + + def test_pass_happy_path_with_reference(self): """ - Request is valid. + Request is valid with reference. """ request = { 'depth': 100, + 'reference': TransactionHash(self.trytes1), } filter_ = self._filter(request) @@ -115,6 +139,38 @@ def test_fail_depth_too_small(self): }, ) + def test_fail_reference_wrong_type(self): + """ + ``reference`` is not a TrytesCompatible value. + """ + self.assertFilterErrors( + { + 'reference': 42, + + 'depth': 100, + }, + + { + 'reference': [f.Type.CODE_WRONG_TYPE], + }, + ) + + def test_fail_reference_not_trytes(self): + """ + ``reference`` contains invalid characters. + """ + self.assertFilterErrors( + { + 'reference': b'not valid; must contain only uppercase and "9"', + + 'depth': 100, + }, + + { + 'reference': [Trytes.CODE_NOT_TRYTES], + }, + ) + class GetTransactionsToApproveResponseFilterTestCase(BaseFilterTestCase): filter_type =\ diff --git a/test/commands/extended/promote_transaction_test.py b/test/commands/extended/promote_transaction_test.py new file mode 100644 index 00000000..21f5b815 --- /dev/null +++ b/test/commands/extended/promote_transaction_test.py @@ -0,0 +1,371 @@ +# 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 six import binary_type + +from iota import Bundle, Iota, TransactionHash, TransactionTrytes, BadApiResponse +from iota.adapter import MockAdapter +from iota.commands.extended.promote_transaction import PromoteTransactionCommand +from iota.filters import Trytes +from test import mock + + +class PromoteTransactionRequestFilterTestCase(BaseFilterTestCase): + filter_type = PromoteTransactionCommand(MockAdapter()).get_request_filter + skip_value_check = True + + # noinspection SpellCheckingInspection + def setUp(self): + super(PromoteTransactionRequestFilterTestCase, self).setUp() + + self.trytes1 = ( + b'TESTVALUEONE9DONTUSEINPRODUCTION99999DAU' + b'9WFSFWBSFT9QATCXFIIKDVFLHIIJGGFCDYENBEDCF' + ) + + def test_pass_happy_path(self): + """ + Request is valid. + """ + request = { + 'depth': 100, + 'minWeightMagnitude': 18, + 'transaction': TransactionHash(self.trytes1), + } + + filter_ = self._filter(request) + + self.assertFilterPasses(filter_) + self.assertDictEqual(filter_.cleaned_data, request) + + def test_pass_compatible_types(self): + """ + Request contains values that can be converted to the expected + types. + """ + filter_ = self._filter({ + # This can be any TrytesCompatible value. + 'transaction': binary_type(self.trytes1), + + # These values must still be ints, however. + 'depth': 100, + 'minWeightMagnitude': 18, + }) + + self.assertFilterPasses(filter_) + self.assertDictEqual( + filter_.cleaned_data, + + { + 'depth': 100, + 'minWeightMagnitude': 18, + 'transaction': TransactionHash(self.trytes1), + }, + ) + + def test_fail_empty(self): + """ + Request is empty. + """ + self.assertFilterErrors( + {}, + + { + 'depth': [f.FilterMapper.CODE_MISSING_KEY], + 'minWeightMagnitude': [f.FilterMapper.CODE_MISSING_KEY], + 'transaction': [f.FilterMapper.CODE_MISSING_KEY], + }, + ) + + def test_fail_unexpected_parameters(self): + """ + Request contains unexpected parameters. + """ + self.assertFilterErrors( + { + 'depth': 100, + 'minWeightMagnitude': 18, + 'transaction': TransactionHash(self.trytes1), + + # That's a real nasty habit you got there. + 'foo': 'bar', + }, + + { + 'foo': [f.FilterMapper.CODE_EXTRA_KEY], + }, + ) + + def test_fail_transaction_null(self): + """ + ``transaction`` is null. + """ + self.assertFilterErrors( + { + 'transaction': None, + + 'depth': 100, + 'minWeightMagnitude': 18, + }, + + { + 'transaction': [f.Required.CODE_EMPTY], + }, + ) + + def test_fail_transaction_wrong_type(self): + """ + ``transaction`` is not a TrytesCompatible value. + """ + self.assertFilterErrors( + { + 'transaction': 42, + + 'depth': 100, + 'minWeightMagnitude': 18, + }, + + { + 'transaction': [f.Type.CODE_WRONG_TYPE], + }, + ) + + def test_fail_transaction_not_trytes(self): + """ + ``transaction`` contains invalid characters. + """ + self.assertFilterErrors( + { + 'transaction': b'not valid; must contain only uppercase and "9"', + + 'depth': 100, + 'minWeightMagnitude': 18, + }, + + { + 'transaction': [Trytes.CODE_NOT_TRYTES], + }, + ) + + def test_fail_depth_null(self): + """ + ``depth`` is null. + """ + self.assertFilterErrors( + { + 'depth': None, + + 'minWeightMagnitude': 18, + 'transaction': TransactionHash(self.trytes1), + }, + + { + 'depth': [f.Required.CODE_EMPTY], + }, + ) + + def test_fail_depth_string(self): + """ + ``depth`` is a string. + """ + self.assertFilterErrors( + { + # Too ambiguous; it's gotta be an int. + 'depth': '4', + + 'minWeightMagnitude': 18, + 'transaction': TransactionHash(self.trytes1), + }, + + { + 'depth': [f.Type.CODE_WRONG_TYPE], + }, + ) + + def test_fail_depth_float(self): + """ + ``depth`` is a float. + """ + self.assertFilterErrors( + { + # Even with an empty fpart, float value is not valid. + 'depth': 8.0, + + 'minWeightMagnitude': 18, + 'transaction': TransactionHash(self.trytes1), + }, + + { + 'depth': [f.Type.CODE_WRONG_TYPE], + }, + ) + + def test_fail_depth_too_small(self): + """ + ``depth`` is < 1. + """ + self.assertFilterErrors( + { + 'depth': 0, + + 'minWeightMagnitude': 18, + 'transaction': TransactionHash(self.trytes1), + }, + + { + 'depth': [f.Min.CODE_TOO_SMALL], + }, + ) + + def test_fail_min_weight_magnitude_null(self): + """ + ``minWeightMagnitude`` is null. + """ + self.assertFilterErrors( + { + 'minWeightMagnitude': None, + + 'depth': 100, + 'transaction': TransactionHash(self.trytes1), + }, + + { + 'minWeightMagnitude': [f.Required.CODE_EMPTY], + }, + ) + + def test_fail_min_weight_magnitude_string(self): + """ + ``minWeightMagnitude`` is a string. + """ + self.assertFilterErrors( + { + # It's gotta be an int! + 'minWeightMagnitude': '18', + + 'depth': 100, + 'transaction': TransactionHash(self.trytes1), + }, + + { + 'minWeightMagnitude': [f.Type.CODE_WRONG_TYPE], + }, + ) + + def test_fail_min_weight_magnitude_float(self): + """ + ``minWeightMagnitude`` is a float. + """ + self.assertFilterErrors( + { + # Even with an empty fpart, float values are not valid. + 'minWeightMagnitude': 18.0, + + 'depth': 100, + 'transaction': TransactionHash(self.trytes1), + }, + + { + 'minWeightMagnitude': [f.Type.CODE_WRONG_TYPE], + }, + ) + + def test_fail_min_weight_magnitude_too_small(self): + """ + ``minWeightMagnitude`` is < 1. + """ + self.assertFilterErrors( + { + 'minWeightMagnitude': 0, + + 'depth': 100, + 'transaction': TransactionHash(self.trytes1), + }, + + { + 'minWeightMagnitude': [f.Min.CODE_TOO_SMALL], + }, + ) + + +class PromoteTransactionCommandTestCase(TestCase): + def setUp(self): + super(PromoteTransactionCommandTestCase, self).setUp() + + self.adapter = MockAdapter() + self.command = PromoteTransactionCommand(self.adapter) + + self.trytes1 = b'RBTC9D9DCDQAEASBYBCCKBFA' + self.trytes2 =\ + b'CCPCBDVC9DTCEAKDXC9D9DEARCWCPCBDVCTCEAHDWCTCEAKDCDFD9DSCSA' + + self.hash1 = TransactionHash( + b'TESTVALUE9DONTUSEINPRODUCTION99999TBPDM9' + b'ADFAWCKCSFUALFGETFIFG9UHIEFE9AYESEHDUBDDF' + ) + + def test_wireup(self): + """ + Verifies that the command is wired-up correctly. + """ + self.assertIsInstance( + Iota(self.adapter).promoteTransaction, + PromoteTransactionCommand, + ) + + def test_happy_path(self): + """ + Successfully promoting a bundle. + """ + + self.adapter.seed_response('checkConsistency', { + 'state': True, + }) + + result_bundle = Bundle.from_tryte_strings([ + TransactionTrytes(self.trytes1), + TransactionTrytes(self.trytes2), + ]) + mock_send_transfer = mock.Mock(return_value={ + 'bundle': result_bundle, + }) + + with mock.patch( + 'iota.commands.extended.send_transfer.SendTransferCommand._execute', + mock_send_transfer, + ): + + response = self.command( + transaction=self.hash1, + depth=3, + minWeightMagnitude=16, + ) + + self.assertDictEqual( + response, + + { + 'bundle': result_bundle, + } + ) + + def test_not_promotable(self): + """ + Bundle isn't promotable. + """ + + self.adapter.seed_response('checkConsistency', { + 'state': False, + }) + + with self.assertRaises(BadApiResponse): + response = self.command( + transaction=self.hash1, + depth=3, + minWeightMagnitude=16, + ) diff --git a/test/commands/extended/send_transfer_test.py b/test/commands/extended/send_transfer_test.py index 8893743f..38534859 100644 --- a/test/commands/extended/send_transfer_test.py +++ b/test/commands/extended/send_transfer_test.py @@ -9,7 +9,7 @@ from six import binary_type, text_type from iota import Address, Bundle, Iota, ProposedTransaction, \ - TransactionTrytes, TryteString + TransactionTrytes, TryteString, TransactionHash from iota.adapter import MockAdapter from iota.commands.extended.send_transfer import SendTransferCommand from iota.crypto.types import Seed @@ -87,6 +87,8 @@ def test_pass_happy_path(self): self.transfer1, self.transfer2 ], + + 'reference': TransactionHash(self.trytes1), } filter_ = self._filter(request) @@ -103,6 +105,7 @@ def test_pass_compatible_types(self): # Any TrytesCompatible values will work here. 'changeAddress': binary_type(self.trytes1), 'seed': bytearray(self.trytes2), + 'reference': binary_type(self.trytes1), 'inputs': [ binary_type(self.trytes3), @@ -128,6 +131,7 @@ def test_pass_compatible_types(self): 'depth': 100, 'minWeightMagnitude': 18, 'seed': Seed(self.trytes2), + 'reference': TransactionHash(self.trytes1), 'inputs': [ Address(self.trytes3), @@ -163,6 +167,7 @@ def test_pass_optional_parameters_omitted(self): { 'changeAddress': None, 'inputs': None, + 'reference': None, 'depth': 100, 'minWeightMagnitude': 13, @@ -570,6 +575,44 @@ def test_fail_min_weight_magnitude_too_small(self): }, ) + def test_fail_reference_wrong_type(self): + """ + ``reference`` is not a TrytesCompatible value. + """ + self.assertFilterErrors( + { + 'reference': 42, + + 'seed': Seed(self.trytes1), + 'depth': 100, + 'minWeightMagnitude': 18, + 'transfers': [self.transfer1], + }, + + { + 'reference': [f.Type.CODE_WRONG_TYPE], + }, + ) + + def test_fail_reference_not_trytes(self): + """ + ``reference`` contains invalid characters. + """ + self.assertFilterErrors( + { + 'reference': b'not valid; must contain only uppercase and "9"', + + 'seed': Seed(self.trytes1), + 'depth': 100, + 'minWeightMagnitude': 18, + 'transfers': [self.transfer1], + }, + + { + 'reference': [Trytes.CODE_NOT_TRYTES], + }, + ) + class SendTransferCommandTestCase(TestCase): def setUp(self): diff --git a/test/commands/extended/send_trytes_test.py b/test/commands/extended/send_trytes_test.py index fd39482c..b2d4d02e 100644 --- a/test/commands/extended/send_trytes_test.py +++ b/test/commands/extended/send_trytes_test.py @@ -8,7 +8,7 @@ from filters.test import BaseFilterTestCase from six import binary_type, text_type -from iota import Iota, TransactionTrytes, TryteString +from iota import Iota, TransactionTrytes, TryteString, TransactionHash from iota.adapter import MockAdapter from iota.commands.extended.send_trytes import SendTrytesCommand from iota.filters import Trytes @@ -40,6 +40,8 @@ def test_pass_happy_path(self): TransactionTrytes(self.trytes1), TransactionTrytes(self.trytes2), ], + + 'reference': TransactionHash(self.trytes1), } filter_ = self._filter(request) @@ -58,6 +60,7 @@ def test_pass_compatible_types(self): binary_type(self.trytes1), bytearray(self.trytes2), ], + 'reference': binary_type(self.trytes2), # These still have to be ints, however. 'depth': 100, @@ -76,6 +79,7 @@ def test_pass_compatible_types(self): TransactionTrytes(self.trytes1), TransactionTrytes(self.trytes2), ], + 'reference': TransactionHash(self.trytes2) }, )