From 07f344c62872dbff46439bcdc4b05fcf5617e1bd Mon Sep 17 00:00:00 2001 From: Levente Pap Date: Tue, 22 Oct 2019 11:40:03 +0200 Subject: [PATCH 1/2] Implement broadcast_bundle() Api Command As part of the extended api (Iota class), this function takes a tail transaction hash of a bundle and re-broadcasts the entire bundle. It does so by fetching and validating the bundle from the Tangle, and calling broadcast_transactions() core api. --- docs/api.rst | 20 ++ iota/api.py | 26 +++ iota/commands/extended/__init__.py | 1 + iota/commands/extended/broadcast_bundle.py | 51 +++++ .../extended/broadcast_bundle_test.py | 205 ++++++++++++++++++ 5 files changed, 303 insertions(+) create mode 100644 iota/commands/extended/broadcast_bundle.py create mode 100644 test/commands/extended/broadcast_bundle_test.py diff --git a/docs/api.rst b/docs/api.rst index 4c127f0..bcb3d18 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -37,6 +37,26 @@ This method returns a ``dict`` with the following items: broadcast/stored. Should be the same as the value of the ``trytes`` parameter. +``broadcast_bundle`` +----------------------- + +Re-broadcasts all transactions in a bundle given the tail transaction hash. +It might be useful when transactions did not properly propagate, +particularly in the case of large bundles. + +Parameters +~~~~~~~~~~ + +- ``tail_hash: TransactionHash``: Transaction hash of the tail transaction + of the bundle. + +Return +~~~~~~ + +This method returns a ``dict`` with the following items: + +- ``trytes: List[TransactionTrytes]``: Transaction trytes that were + broadcast. ``find_transaction_objects`` ---------------------------- diff --git a/iota/api.py b/iota/api.py index e5eec63..85bbb64 100644 --- a/iota/api.py +++ b/iota/api.py @@ -585,6 +585,32 @@ def broadcast_and_store(self, trytes): """ return extended.BroadcastAndStoreCommand(self.adapter)(trytes=trytes) + def broadcast_bundle(self, tail_transaction_hash): + # type (TransactionHash) -> dict + """ + Re-broadcasts all transactions in a bundle given the tail transaction hash. + It might be useful when transactions did not properly propagate, + particularly in the case of large bundles. + + :param tail_transaction_hash: + Tail transaction hash of the bundle. + + :return: + Dict with the following structure::: + + { + 'trytes': List[TransactionTrytes], + List of TransactionTrytes that were broadcast. + } + + References: + + - https://github.com/iotaledger/iota.js/blob/next/api_reference.md#module_core.broadcastBundle + """ + + return extended.BroadcastBundleCommand(self.adapter)(tail_hash=tail_transaction_hash) + + def find_transaction_objects( self, bundles=None, # type: Optional[Iterable[BundleHash]] diff --git a/iota/commands/extended/__init__.py b/iota/commands/extended/__init__.py index efb9ff8..e9e55c0 100644 --- a/iota/commands/extended/__init__.py +++ b/iota/commands/extended/__init__.py @@ -12,6 +12,7 @@ unicode_literals from .broadcast_and_store import * +from .broadcast_bundle import * from .find_transaction_objects import * from .get_account_data import * from .get_bundles import * diff --git a/iota/commands/extended/broadcast_bundle.py b/iota/commands/extended/broadcast_bundle.py new file mode 100644 index 0000000..4cf73a5 --- /dev/null +++ b/iota/commands/extended/broadcast_bundle.py @@ -0,0 +1,51 @@ +# coding=utf-8 +from __future__ import absolute_import, division, print_function, \ + unicode_literals + +import filters as f +from iota.filters import Trytes + +from iota import TransactionTrytes, TransactionHash +from iota.commands.core import \ + BroadcastTransactionsCommand +from iota.commands.extended.get_bundles import GetBundlesCommand +from iota.commands import FilterCommand, RequestFilter + +__all__ = [ + 'BroadcastBundleCommand', +] + + +class BroadcastBundleCommand(FilterCommand): + """ + Executes ``broadcastBundle`` extended API command. + + See :py:meth:`iota.api.Iota.broadcast_bundle` for more info. + """ + command = 'broadcastBundle' + + def get_request_filter(self): + return BroadcastBundleRequestFilter() + + def get_response_filter(self): + # Return value is filtered before hitting us. + pass + + def _execute(self, request): + # Given tail hash, fetches the bundle from the tangle + # and validates it. + # Returns List[List[TransactionTrytes]] + # (outer list has one item in current implementation) + bundle = GetBundlesCommand(self.adapter)(transaction=request['tail_hash']) + BroadcastTransactionsCommand(self.adapter)(trytes=bundle[0]) + return { + 'trytes': bundle[0], + } + # Future: Support multiple bundles in getBundlesCommand, and + # then broadcastBundle can do that as well. + +class BroadcastBundleRequestFilter(RequestFilter): + def __init__(self): + super(BroadcastBundleRequestFilter, self).__init__({ + 'tail_hash': f.Required | Trytes(TransactionHash), + }) \ No newline at end of file diff --git a/test/commands/extended/broadcast_bundle_test.py b/test/commands/extended/broadcast_bundle_test.py new file mode 100644 index 0000000..570b202 --- /dev/null +++ b/test/commands/extended/broadcast_bundle_test.py @@ -0,0 +1,205 @@ +# 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, BadApiResponse, Bundle, BundleHash, Fragment, Hash, \ + Iota, Tag, Transaction, TransactionHash, TransactionTrytes, Nonce +from iota.adapter import MockAdapter +from iota.commands.extended.broadcast_bundle import BroadcastBundleCommand +from iota.filters import Trytes + +from six import PY2 + +if PY2: + from mock import MagicMock, patch +else: + from unittest.mock import MagicMock, patch + +# RequestFilterTestCase code reused from get_bundles_test.py +class BroadcastBundleRequestFilterTestCase(BaseFilterTestCase): + filter_type = BroadcastBundleCommand(MockAdapter()).get_request_filter + skip_value_check = True + + def setUp(self): + super(BroadcastBundleRequestFilterTestCase, self).setUp() + + # noinspection SpellCheckingInspection + self.transaction = ( + 'TESTVALUE9DONTUSEINPRODUCTION99999KPZOTR' + 'VDB9GZDJGZSSDCBIX9QOK9PAV9RMDBGDXLDTIZTWQ' + ) + + def test_pass_happy_path(self): + """ + Request is valid. + """ + # Raw trytes are extracted to match the IRI's JSON protocol. + request = { + 'tail_hash': self.transaction, + } + + 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({ + # Any TrytesCompatible value will work here. + 'tail_hash': TransactionHash(self.transaction), + }) + + self.assertFilterPasses(filter_) + self.assertDictEqual( + filter_.cleaned_data, + + { + 'tail_hash': self.transaction, + }, + ) + + def test_fail_empty(self): + """ + Request is empty. + """ + self.assertFilterErrors( + {}, + + { + 'tail_hash': [f.FilterMapper.CODE_MISSING_KEY], + }, + ) + + def test_fail_unexpected_parameters(self): + """ + Request contains unexpected parameters. + """ + self.assertFilterErrors( + { + 'tail_hash': TransactionHash(self.transaction), + + # SAY "WHAT" AGAIN! + 'what': 'augh!', + }, + + { + 'what': [f.FilterMapper.CODE_EXTRA_KEY], + }, + ) + + def test_fail_transaction_wrong_type(self): + """ + ``tail_hash`` is not a TrytesCompatible value. + """ + self.assertFilterErrors( + { + 'tail_hash': 42, + }, + + { + 'tail_hash': [f.Type.CODE_WRONG_TYPE], + }, + ) + + def test_fail_transaction_not_trytes(self): + """ + ``tail_hash`` contains invalid characters. + """ + self.assertFilterErrors( + { + 'tail_hash': b'not valid; must contain only uppercase and "9"', + }, + + { + 'tail_hash': [Trytes.CODE_NOT_TRYTES], + }, + ) + +class BroadcastBundleCommandTestCase(TestCase): + def setUp(self): + super(BroadcastBundleCommandTestCase, self).setUp() + + self.adapter = MockAdapter() + self.command = BroadcastBundleCommand(self.adapter) + + self.tail = ( + 'TESTVALUE9DONTUSEINPRODUCTION99999999999' + ) + + self.trytes = [ + 'TESTVALUE9DONTUSEINPRODUCTION99999TRYTESFORTRANSACTION1', + 'TESTVALUE9DONTUSEINPRODUCTION99999TRYTESFORTRANSACTION2' + ] + + self.trytes_dummy = [ + 'TESTVALUE9DONTUSEINPRODUCTION99999TRYTESFORTRANSACTION3', + 'TESTVALUE9DONTUSEINPRODUCTION99999TRYTESFORTRANSACTION4' + ] + + def test_wireup(self): + """ + Verify that the command is wired up correctly. + """ + self.assertIsInstance( + Iota(self.adapter).broadcastBundle, + BroadcastBundleCommand, + ) + + def test_happy_path(self): + """ + Test command flow executes as expected. + """ + # Call the command with a tail hash. + # Lets mock away GetBundlesCommand, and we don't do + # BroadcastTransactionsCommand either. + # We could seed a response to our MockAdapter, but then we shall provide + # valid values to pass GetBundlesRequestFilter. Instead we mock away the + # whole command, so no filter is applied. It is safe because it is tested + # elsewhere. + with patch('iota.commands.extended.get_bundles.GetBundlesCommand.__call__', + MagicMock(return_value=[self.trytes])) as mocked_get_bundles: + # We could seed a reponse to our MockAdapter, but then the returned value + # from `GetBundlesCommand` shall be valid to pass + # BroadcastTransactionRequestFilter. + # Anyway, nature loves symmetry and so do we. + with patch('iota.commands.core.BroadcastTransactionsCommand.__call__', + MagicMock(return_value= [])) as mocked_broadcast: + + response = self.command(tail_hash=self.tail) + + self.assertEqual( + response['trytes'], + self.trytes + ) + + def test_happy_path_multiple_bundle(self): + """ + Test if command returns the correct bundle if underlying `get_bundles` + returns multiple bundles. + """ + # Call the command with a tail hash. + # Lets mock away GetBundlesCommand, and we don't do + # BroadcastTransactionsCommand either. + # Note that GetBundlesCommand returns multiple bundles! + with patch('iota.commands.extended.get_bundles.GetBundlesCommand.__call__', + MagicMock(return_value=[self.trytes, self.trytes_dummy]) + ) as mocked_get_bundles: + with patch('iota.commands.core.BroadcastTransactionsCommand.__call__', + MagicMock(return_value= [])) as mocked_broadcast: + + response = self.command(tail_hash=self.tail) + + # Expect only the first bundle + self.assertEqual( + response['trytes'], + self.trytes + ) \ No newline at end of file From 35a274cf4fe9c6b917c698c3c33ae1d3b9bf66fb Mon Sep 17 00:00:00 2001 From: Levente Pap Date: Wed, 23 Oct 2019 12:15:45 +0200 Subject: [PATCH 2/2] Code polish after PR review --- iota/api.py | 3 +-- iota/commands/extended/broadcast_bundle.py | 2 -- test/commands/extended/broadcast_bundle_test.py | 8 ++++---- 3 files changed, 5 insertions(+), 8 deletions(-) diff --git a/iota/api.py b/iota/api.py index 85bbb64..3e0a55e 100644 --- a/iota/api.py +++ b/iota/api.py @@ -596,7 +596,7 @@ def broadcast_bundle(self, tail_transaction_hash): Tail transaction hash of the bundle. :return: - Dict with the following structure::: + Dict with the following structure:: { 'trytes': List[TransactionTrytes], @@ -610,7 +610,6 @@ def broadcast_bundle(self, tail_transaction_hash): return extended.BroadcastBundleCommand(self.adapter)(tail_hash=tail_transaction_hash) - def find_transaction_objects( self, bundles=None, # type: Optional[Iterable[BundleHash]] diff --git a/iota/commands/extended/broadcast_bundle.py b/iota/commands/extended/broadcast_bundle.py index 4cf73a5..a4cb893 100644 --- a/iota/commands/extended/broadcast_bundle.py +++ b/iota/commands/extended/broadcast_bundle.py @@ -41,8 +41,6 @@ def _execute(self, request): return { 'trytes': bundle[0], } - # Future: Support multiple bundles in getBundlesCommand, and - # then broadcastBundle can do that as well. class BroadcastBundleRequestFilter(RequestFilter): def __init__(self): diff --git a/test/commands/extended/broadcast_bundle_test.py b/test/commands/extended/broadcast_bundle_test.py index 570b202..b6014ac 100644 --- a/test/commands/extended/broadcast_bundle_test.py +++ b/test/commands/extended/broadcast_bundle_test.py @@ -159,7 +159,7 @@ def test_happy_path(self): Test command flow executes as expected. """ # Call the command with a tail hash. - # Lets mock away GetBundlesCommand, and we don't do + # Let's mock away GetBundlesCommand, and we don't do # BroadcastTransactionsCommand either. # We could seed a response to our MockAdapter, but then we shall provide # valid values to pass GetBundlesRequestFilter. Instead we mock away the @@ -172,7 +172,7 @@ def test_happy_path(self): # BroadcastTransactionRequestFilter. # Anyway, nature loves symmetry and so do we. with patch('iota.commands.core.BroadcastTransactionsCommand.__call__', - MagicMock(return_value= [])) as mocked_broadcast: + MagicMock(return_value=[])) as mocked_broadcast: response = self.command(tail_hash=self.tail) @@ -187,14 +187,14 @@ def test_happy_path_multiple_bundle(self): returns multiple bundles. """ # Call the command with a tail hash. - # Lets mock away GetBundlesCommand, and we don't do + # Let's mock away GetBundlesCommand, and we don't do # BroadcastTransactionsCommand either. # Note that GetBundlesCommand returns multiple bundles! with patch('iota.commands.extended.get_bundles.GetBundlesCommand.__call__', MagicMock(return_value=[self.trytes, self.trytes_dummy]) ) as mocked_get_bundles: with patch('iota.commands.core.BroadcastTransactionsCommand.__call__', - MagicMock(return_value= [])) as mocked_broadcast: + MagicMock(return_value=[])) as mocked_broadcast: response = self.command(tail_hash=self.tail)