diff --git a/hathor/transaction/block.py b/hathor/transaction/block.py index 80f9ee67d..a9a1b4e00 100644 --- a/hathor/transaction/block.py +++ b/hathor/transaction/block.py @@ -16,7 +16,7 @@ from itertools import starmap, zip_longest from operator import add from struct import pack -from typing import TYPE_CHECKING, Any, Optional +from typing import TYPE_CHECKING, Any, Iterator, Optional from hathor.checkpoint import Checkpoint from hathor.feature_activation.feature import Feature @@ -401,3 +401,14 @@ def get_feature_activation_bit_value(self, bit: int) -> int: bit_list = self._get_feature_activation_bit_list() return bit_list[bit] + + def iter_transactions_in_this_block(self) -> Iterator[BaseTransaction]: + """Return an iterator of the transactions that have this block as meta.first_block.""" + from hathor.transaction.storage.traversal import BFSOrderWalk + bfs = BFSOrderWalk(self.storage, is_dag_verifications=True, is_dag_funds=True, is_left_to_right=False) + for tx in bfs.run(self, skip_root=True): + tx_meta = tx.get_metadata() + if tx_meta.first_block != self.hash: + bfs.skip_neighbors(tx) + continue + yield tx diff --git a/hathor/transaction/resources/block_at_height.py b/hathor/transaction/resources/block_at_height.py index d7b83af1e..9d4be036f 100644 --- a/hathor/transaction/resources/block_at_height.py +++ b/hathor/transaction/resources/block_at_height.py @@ -12,11 +12,12 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any -from hathor.api_util import Resource, get_args, get_missing_params_msg, parse_args, parse_int, set_cors +from hathor.api_util import Resource, set_cors from hathor.cli.openapi_files.register import register_resource from hathor.util import json_dumpb +from hathor.utils.api import ErrorResponse, QueryParams if TYPE_CHECKING: from twisted.web.http import Request @@ -48,38 +49,52 @@ def render_GET(self, request: 'Request') -> bytes: request.setHeader(b'content-type', b'application/json; charset=utf-8') set_cors(request, 'GET') - # Height parameter is required - parsed = parse_args(get_args(request), ['height']) - if not parsed['success']: - return get_missing_params_msg(parsed['missing']) + params = BlockAtHeightParams.from_request(request) + if isinstance(params, ErrorResponse): + return params.json_dumpb() - args = parsed['args'] + # Get hash of the block with the height + block_hash = self.manager.tx_storage.indexes.height.get(params.height) - # Height parameter must be an integer - try: - height = parse_int(args['height']) - except ValueError as e: + # If there is no block in the index with this height, block_hash will be None + if block_hash is None: return json_dumpb({ 'success': False, - 'message': f'Failed to parse \'height\': {e}' + 'message': 'No block with height {}.'.format(params.height) }) - # Get hash of the block with the height - block_hash = self.manager.tx_storage.indexes.height.get(height) + block = self.manager.tx_storage.get_block(block_hash) + data = {'success': True, 'block': block.to_json_extended()} - # If there is no block in the index with this height, block_hash will be None - if block_hash is None: + if params.include_transactions is None: + pass + + elif params.include_transactions == 'txid': + tx_ids: list[str] = [] + for tx in block.iter_transactions_in_this_block(): + tx_ids.append(tx.hash.hex()) + data['tx_ids'] = tx_ids + + elif params.include_transactions == 'full': + tx_list: list[Any] = [] + for tx in block.iter_transactions_in_this_block(): + tx_list.append(tx.to_json_extended()) + data['transactions'] = tx_list + + else: return json_dumpb({ 'success': False, - 'message': 'No block with height {}.'.format(height) + 'message': 'Invalid include_transactions. Choices are: txid or full.' }) - block = self.manager.tx_storage.get_transaction(block_hash) - - data = {'success': True, 'block': block.to_json_extended()} return json_dumpb(data) +class BlockAtHeightParams(QueryParams): + height: int + include_transactions: str | None + + BlockAtHeightResource.openapi = { '/block_at_height': { 'x-visibility': 'public', @@ -114,6 +129,19 @@ def render_GET(self, request: 'Request') -> bytes: 'type': 'int' } }, + { + 'name': 'include_transactions', + 'in': 'query', + 'description': 'Add transactions confirmed by this block.', + 'required': False, + 'schema': { + 'type': 'string', + 'enum': [ + 'txid', + 'full', + ], + } + }, ], 'responses': { '200': { diff --git a/tests/resources/transaction/test_block_at_height.py b/tests/resources/transaction/test_block_at_height.py index 9800b3816..c7c006e8e 100644 --- a/tests/resources/transaction/test_block_at_height.py +++ b/tests/resources/transaction/test_block_at_height.py @@ -1,9 +1,10 @@ from twisted.internet.defer import inlineCallbacks -from hathor.simulator.utils import add_new_blocks +from hathor.simulator.utils import add_new_block, add_new_blocks from hathor.transaction.resources import BlockAtHeightResource from tests import unittest from tests.resources.base_resource import StubSite, _BaseResourceTest +from tests.utils import add_blocks_unlock_reward, add_new_tx class BaseBlockAtHeightTest(_BaseResourceTest._ResourceTest): @@ -14,6 +15,62 @@ def setUp(self): self.web = StubSite(BlockAtHeightResource(self.manager)) self.manager.wallet.unlock(b'MYPASS') + @inlineCallbacks + def test_include_full(self): + add_new_block(self.manager, advance_clock=1) + add_blocks_unlock_reward(self.manager) + address = self.manager.wallet.get_unused_address() + + confirmed_tx_list = [] + for _ in range(15): + confirmed_tx_list.append(add_new_tx(self.manager, address, 1)) + + block = add_new_block(self.manager, advance_clock=1) + height = block.get_height() + + # non-confirmed transactions + for _ in range(15): + add_new_tx(self.manager, address, 1) + + response = yield self.web.get("block_at_height", { + b'height': str(height).encode('ascii'), + b'include_transactions': b'full', + }) + data = response.json_value() + + self.assertTrue(data['success']) + response_tx_ids = set(x['tx_id'] for x in data['transactions']) + expected_tx_ids = set(tx.hash.hex() for tx in confirmed_tx_list) + self.assertTrue(response_tx_ids.issubset(expected_tx_ids)) + + @inlineCallbacks + def test_include_txids(self): + add_new_block(self.manager, advance_clock=1) + add_blocks_unlock_reward(self.manager) + address = self.manager.wallet.get_unused_address() + + confirmed_tx_list = [] + for _ in range(15): + confirmed_tx_list.append(add_new_tx(self.manager, address, 1)) + + block = add_new_block(self.manager, advance_clock=1) + height = block.get_height() + + # non-confirmed transactions + for _ in range(15): + add_new_tx(self.manager, address, 1) + + response = yield self.web.get("block_at_height", { + b'height': str(height).encode('ascii'), + b'include_transactions': b'txid', + }) + data = response.json_value() + + self.assertTrue(data['success']) + response_tx_ids = set(data['tx_ids']) + expected_tx_ids = set(tx.hash.hex() for tx in confirmed_tx_list) + self.assertTrue(response_tx_ids.issubset(expected_tx_ids)) + @inlineCallbacks def test_get(self): blocks = add_new_blocks(self.manager, 4, advance_clock=1)