diff --git a/src/sakia/app.py b/src/sakia/app.py index 79cb71edc9a48cf9a6f94fd9cbd2bbc16f0c29ce..b83ae53f1c1cbac8dd94488667ebfa45dbe25999 100644 --- a/src/sakia/app.py +++ b/src/sakia/app.py @@ -117,7 +117,8 @@ class Application(QObject): if currency not in self.transactions_services: self.transactions_services[currency] = TransactionsService(currency, transactions_processor, - identities_processor, bma_connector) + identities_processor, connections_processor, + bma_connector) if currency not in self.blockchain_services: self.blockchain_services[currency] = BlockchainService(self, currency, blockchain_processor, bma_connector, diff --git a/src/sakia/data/entities/transaction.py b/src/sakia/data/entities/transaction.py index a014b2de2e565608dd02ef75e19391bec377053f..fb5e1666f66d5b3df9a7f57341a784ee1e77cd18 100644 --- a/src/sakia/data/entities/transaction.py +++ b/src/sakia/data/entities/transaction.py @@ -78,15 +78,13 @@ class Transaction: :param str amount_base: the amount base :param str comment: a comment :param str txid: the transaction id to sort transctions - :param str state: the state of the transaction + :param int state: the state of the transaction """ TO_SEND = 0 AWAITING = 1 - VALIDATING = 2 VALIDATED = 4 REFUSED = 8 DROPPED = 16 - LOCAL = 128 currency = attr.ib(convert=str, cmp=False) sha_hash = attr.ib(convert=str) @@ -102,8 +100,3 @@ class Transaction: txid = attr.ib(convert=int, cmp=False) state = attr.ib(convert=int, cmp=False) raw = attr.ib(convert=str, cmp=False, default="") - - - @property - def local(self): - return self.state & Transaction.LOCAL == Transaction.LOCAL diff --git a/src/sakia/data/processors/transactions.py b/src/sakia/data/processors/transactions.py index d573ff0a9ee6a1af13c5f576e415384506ce6933..71d06be76e2f37c7615b8f8fef4a4eb2a88325c9 100644 --- a/src/sakia/data/processors/transactions.py +++ b/src/sakia/data/processors/transactions.py @@ -1,3 +1,4 @@ +import logging import attr import asyncio import sqlite3 @@ -16,6 +17,7 @@ class TransactionsProcessor: _repo = attr.ib() # :type sakia.data.repositories.SourcesRepo _bma_connector = attr.ib() # :type sakia.data.connectors.bma.BmaConnector _table_states = attr.ib(default=attr.Factory(dict)) + _logger = attr.ib(default=attr.Factory(lambda: logging.getLogger('sakia'))) @classmethod def instanciate(cls, app): @@ -57,30 +59,35 @@ class TransactionsProcessor: """ if len(inputs) == len(transition_key[1]): for i, input in enumerate(inputs): - if type(input) is not transition_key[1][i]: + if not isinstance(input, transition_key[1][i]): return False for transition in tx_lifecycle.states[transition_key]: - if transition[0](*inputs): + if transition[0](tx, *inputs): if tx.sha_hash: self._logger.debug("{0} : {1} --> {2}".format(tx.sha_hash[:5], tx.state, - transition[2].name)) + transition[2])) else: self._logger.debug("Unsent transfer : {0} --> {1}".format(tx.state, - transition[2].name)) + transition[2])) # If the transition changes data, apply changes if transition[1]: transition[1](tx, *inputs) - tx.state = transition[2] | tx.local + tx.state = transition[2] return True return False + def commit(self, tx): + try: + self._repo.insert(tx) + except sqlite3.IntegrityError: + self._repo.update(tx) + def find_by_hash(self, sha_hash): - return self._repo.find_one(sha_hash=sha_hash) + return self._repo.get_one(sha_hash=sha_hash) def awaiting(self, currency): - return self._repo.get_all(currency=currency, state=Transaction.AWAITING) + \ - self._repo.get_all(currency=currency, state=Transaction.AWAITING | Transaction.LOCAL) + return self._repo.get_all(currency=currency, state=Transaction.AWAITING) def run_state_transitions(self, tx, *inputs): """ @@ -90,9 +97,10 @@ class TransactionsProcessor: :return: True if the transaction changed state :rtype: bool """ - transition_keys = [k for k in tx_lifecycle.states.keys() if k[0] | Transaction.LOCAL == tx.state] + transition_keys = [k for k in tx_lifecycle.states.keys() if k[0] == tx.state] for key in transition_keys: if self._try_transition(tx, key, inputs): + self._repo.update(tx) return True return False @@ -123,7 +131,7 @@ class TransactionsProcessor: result = (False, (await r.text())) else: await r.text() - self.run_state_transitions(tx, ([r.status for r in responses],)) + self.run_state_transitions(tx, [r.status for r in responses]) return result, tx async def initialize_transactions(self, identity, log_stream): diff --git a/src/sakia/data/processors/tx_lifecycle.py b/src/sakia/data/processors/tx_lifecycle.py index 7769bab6d77f76cd2374d968f4281064df6e8d83..37b89567dcc9102a846434ce3eafc568b02ebe24 100644 --- a/src/sakia/data/processors/tx_lifecycle.py +++ b/src/sakia/data/processors/tx_lifecycle.py @@ -3,43 +3,20 @@ from sakia.data.entities import Transaction from duniterpy.documents import Block -def _not_found_in_blockchain(tx, rollback, block, mediantime_target, mediantime_blocks): - """ - Check if the transaction could not be found in the blockchain - :param sakia.data.entities.Transaction tx: the transaction - :param bool rollback: True if we are in a rollback procedure - :param duniterpy.documents.Block block: The block to look for the tx - :param int mediantime_target: The mediantime to mine a block in the community parameters - :param int mediantime_blocks: The number of block used to derive the mediantime - :return: True if the transaction could not be found in a given time - :rtype: bool - """ - if not rollback: - for block_tx in block.transactions: - if block_tx.sha_hash == tx.sha_hash: - return False - if block.time > tx.timestamp + mediantime_target * mediantime_blocks: - return True - return False - - -def _found_in_block(tx, rollback, block): +def _found_in_block(tx, block): """ Check if the transaction can be found in the blockchain :param sakia.data.entities.Transaction tx: the transaction - :param bool rollback: True if we are in a rollback procedure :param duniterpy.documents.Block block: The block to check for the transaction :return: True if the transaction was found :rtype: bool """ - if not rollback: - for block_tx in block.transactions: - if block_tx.sha_hash == tx.sha_hash: - return True - return False + for block_tx in block.transactions: + if block_tx.sha_hash == tx.sha_hash: + return True -def _broadcast_success(tx, ret_codes, block): +def _broadcast_success(tx, ret_codes): """ Check if the retcode is 200 after a POST :param sakia.data.entities.Transaction tx: the transaction @@ -62,19 +39,6 @@ def _broadcast_failure(tx, ret_codes): return 200 not in ret_codes -def _reached_enough_confrmation(tx, rollback, current_block, fork_window): - """ - Check if the transfer reached enough confrmation in the blockchain - :param sakia.data.entities.Transaction tx: the transaction - :param bool rollback: True if we are in a rollback procedure - :param duniterpy.documents.Block current_block: The current block of the main blockchain - :param int fork_window: The number of confrmations needed on the network - :return: True if the transfer reached enough confrmations - :rtype: bool - """ - return not rollback and tx.blockstamp.number + fork_window <= current_block.number - - def _rollback_and_removed(tx, rollback, block): """ Check if the transfer is not in the block anymore @@ -92,20 +56,6 @@ def _rollback_and_removed(tx, rollback, block): return False -def _rollback_in_fork_window(tx, rollback, current_block, fork_window): - """ - Check if the transfer is not in the block anymore - - :param sakia.data.entities.Transaction tx: the transaction - :param bool rollback: True if we are in a rollback procedure - :param duniterpy.documents.Block current_block: The block to check for the transaction - :return: True if the transfer is found in the block - """ - if rollback: - return tx.blockstamp.number + fork_window > current_block.number - return False - - def _rollback_and_local(tx, rollback, block): """ Check if the transfer is not in the block anymore @@ -129,7 +79,8 @@ def _is_locally_created(tx): """ return tx.local -def _be_validating(tx, block): + +def _be_validated(tx, block): """ Action when the transfer ins found in a block @@ -162,18 +113,8 @@ states = { (Transaction.TO_SEND, ()): ((_is_locally_created, _drop, Transaction.DROPPED),), - (Transaction.AWAITING, (bool, Block)): - ((_found_in_block, lambda tx, r, b: _be_validating(tx, b), Transaction.VALIDATING),), - (Transaction.AWAITING, (bool, Block, int, int)): - ((_not_found_in_blockchain, None, Transaction.REFUSED),), - - (Transaction.VALIDATING, (bool, Block, int)): - ((_reached_enough_confrmation, None, Transaction.VALIDATED),), - (Transaction.VALIDATING, (bool, Block)): - ((_rollback_and_removed, lambda tx, r, b: _drop(tx), Transaction.DROPPED),), - - (Transaction.VALIDATED, (bool, Block, int)): - ((_rollback_in_fork_window, lambda tx, r, b, i: _be_validating(tx, b), Transaction.VALIDATING),), + (Transaction.AWAITING, (Block,)): + ((_found_in_block, _be_validated, Transaction.VALIDATED),), (Transaction.VALIDATED, (bool,)): ( diff --git a/src/sakia/gui/navigation/txhistory/table_model.py b/src/sakia/gui/navigation/txhistory/table_model.py index c587b9d2153e89b8b051300288127ae299a86608..a8d6e5d5e60fc4ecbab09d8a0410ed81a58c72b9 100644 --- a/src/sakia/gui/navigation/txhistory/table_model.py +++ b/src/sakia/gui/navigation/txhistory/table_model.py @@ -157,18 +157,20 @@ class TxFilterProxyModel(QSortFilterProxyModel): if source_index.column() == self.sourceModel().columns_types.index('date'): return QDateTime.fromTime_t(source_data).toString(Qt.SystemLocaleLongDate) - if state_data == Transaction.VALIDATING or state_data == Transaction.AWAITING: + if state_data == Transaction.VALIDATED or state_data == Transaction.AWAITING: block_col = model.columns_types.index('block_number') block_index = model.index(source_index.row(), block_col) block_data = model.data(block_index, Qt.DisplayRole) current_confirmations = 0 - if state_data == Transaction.VALIDATING: + if state_data == Transaction.VALIDATED: current_confirmations = self.blockchain_service.current_buid().number - block_data elif state_data == Transaction.AWAITING: current_confirmations = 0 - if self.app.preferences['expert_mode']: + if current_confirmations >= MAX_CONFIRMATIONS: + return None + elif self.app.preferences['expert_mode']: return self.tr("{0} / {1} confirmations").format(current_confirmations, MAX_CONFIRMATIONS) else: confirmation = current_confirmations / MAX_CONFIRMATIONS * 100 diff --git a/src/sakia/services/blockchain.py b/src/sakia/services/blockchain.py index 1de1e7f8b546e8d2049c9e4a879594b2c2c8282c..3e813eb1a3c419607e4865650be30dfad8828ddc 100644 --- a/src/sakia/services/blockchain.py +++ b/src/sakia/services/blockchain.py @@ -39,7 +39,7 @@ class BlockchainService(QObject): with_money = await self._blockchain_processor.new_blocks_with_money(self.currency) blocks = await self._blockchain_processor.blocks(with_identities + with_money, self.currency) await self._identities_service.handle_new_blocks(blocks) - await self._transactions_service.handle_new_blocks(blocks) + self._transactions_service.handle_new_blocks(blocks) self.app.db.commit() except (NoPeerAvailable, DuniterError) as e: self._logger.debug(str(e)) diff --git a/src/sakia/services/transactions.py b/src/sakia/services/transactions.py index 99b72e4cdc49ac2999ed8c061302cb0c0daac2e2..6bd563cec855b523a38e6c8b77421b0dff43d85e 100644 --- a/src/sakia/services/transactions.py +++ b/src/sakia/services/transactions.py @@ -9,23 +9,25 @@ class TransactionsService(QObject): Transaction service is managing sources received to update data locally """ - def __init__(self, currency, transactions_processor, identities_processor, bma_connector): + def __init__(self, currency, transactions_processor, identities_processor, connections_processor, bma_connector): """ Constructor the identities service :param str currency: The currency name of the community :param sakia.data.processors.IdentitiesProcessor identities_processor: the identities processor for given currency :param sakia.data.processors.TransactionsProcessor transactions_processor: the transactions processor for given currency + :param sakia.data.processors.ConnectionsProcessor connections_processor: the connections processor for given currency :param sakia.data.connectors.BmaConnector bma_connector: The connector to BMA API """ super().__init__() self._transactions_processor = transactions_processor self._identities_processor = identities_processor + self._connections_processor = connections_processor self._bma_connector = bma_connector self.currency = currency self._logger = logging.getLogger('sakia') - async def _parse_block(self, block_doc, txid): + def _parse_block(self, block_doc, txid): """ Parse a block :param duniterpy.documents.Block block_doc: The block @@ -34,22 +36,23 @@ class TransactionsService(QObject): """ transfers = [] for tx in [t for t in self._transactions_processor.awaiting(self.currency)]: - self._transactions_processor.run_state_transitions(tx, (False, block_doc)) + self._transactions_processor.run_state_transitions(tx, block_doc) - new_transactions = [t for t in block_doc.transactions - if not self._transactions_processor.find_by_hash(t.sha_hash) - and SimpleTransaction.is_simple(t)] - - for (i, tx_doc) in enumerate(new_transactions): - tx = parse_transaction_doc(tx_doc, block_doc.blockUID.number, block_doc.mediantime, txid+i) + new_transactions = [t for t in block_doc.transactions + if not self._transactions_processor.find_by_hash(t.sha_hash) + and SimpleTransaction.is_simple(t)] + connections_pubkeys = [c.pubkey for c in self._connections_processor.connections_to(self.currency)] + for (i, tx_doc) in enumerate(new_transactions): + for pubkey in connections_pubkeys: + tx = parse_transaction_doc(tx_doc, pubkey, block_doc.blockUID.number, block_doc.mediantime, txid+i) if tx: - #logging.debug("Transfer amount : {0}".format(transfer.metadata['amount'])) + transfers.append(tx) self._transactions_processor.commit(tx) else: logging.debug("Error during transfer parsing") return transfers - async def handle_new_blocks(self, blocks): + def handle_new_blocks(self, blocks): """ Refresh last transactions @@ -58,7 +61,7 @@ class TransactionsService(QObject): self._logger.debug("Refresh transactions") txid = 0 for block in blocks: - transfers = await self._parse_block(block, txid) + transfers = self._parse_block(block, txid) txid += len(transfers) def transfers(self, pubkey): diff --git a/src/sakia/tests/technical/test_identities_service.py b/src/sakia/tests/technical/test_identities_service.py index 0ef3ade60dfcb233786cdd30bf9033c764c2e2bc..d2e2bb9cf26ae6a4fa92d9828edf8a379cb02209 100644 --- a/src/sakia/tests/technical/test_identities_service.py +++ b/src/sakia/tests/technical/test_identities_service.py @@ -1,38 +1,6 @@ -import unittest -import sqlite3 -from duniterpy.documents import BlockUID, Block -from sakia.tests.mocks.bma.nice_blockchain import bma_blockchain_0 -from sakia.tests import QuamashTest -from sakia.services import IdentitiesService -from sakia.data.repositories import CertificationsRepo, IdentitiesRepo, SakiaDatabase -from sakia.data.processors import CertificationsProcessor, IdentitiesProcessor +import pytest -class TestIdentitiesService(unittest.TestCase, QuamashTest): - def setUp(self): - self.setUpQuamash() - sqlite3.register_adapter(BlockUID, str) - sqlite3.register_adapter(bool, int) - sqlite3.register_adapter(list, lambda ls: '\n'.join([str(v) for v in ls])) - sqlite3.register_adapter(tuple, lambda ls: '\n'.join([str(v) for v in ls])) - sqlite3.register_converter("BOOLEAN", lambda v: bool(int(v))) - self.con = sqlite3.connect(":memory:", detect_types=sqlite3.PARSE_DECLTYPES) - - def tearDown(self): - self.tearDownQuamash() - - def test_new_block_with_unknown_identities(self): - meta_repo = SakiaDatabase(self.con) - meta_repo.prepare() - meta_repo.upgrade_database() - identities_repo = IdentitiesRepo(self.con) - certs_repo = CertificationsRepo(self.con) - identities_processor = IdentitiesProcessor("testcurrency", identities_repo, None) - certs_processor = CertificationsProcessor("testcurrency", certs_repo, None) - identities_service = IdentitiesService("testcurrency", identities_processor, certs_processor, None) - block = Block.from_signed_raw("{0}{1}\n".format(bma_blockchain_0["raw"], bma_blockchain_0["signature"])) - identities_service.parse_block(block) - self.assertEqual(identities_processor.get_written("8Fi1VSTbjkXguwThF4v2ZxC5whK7pwG2vcGTkPUPjPGU"), []) - self.assertEqual(identities_processor.get_written("HnFcSms8jzwngtVomTTnzudZx7SHUQY8sVE1y8yBmULk"), []) - self.assertEqual(identities_processor.get_written("BMAVuMDcGhYAV4wA27DL1VXX2ZARZGJYaMwpf7DJFMYH"), []) - self.assertEqual(identities_processor.get_written("37qBxM4hLV2jfyYo2bNzAjkeLngLr2r7G2HpdpKieVxw"), []) +@pytest.mark.asyncio +async def test_new_block_with_unknown_identities(application_with_one_connection, fake_server, bob, alice): + pass \ No newline at end of file diff --git a/src/sakia/tests/technical/test_transactions_service.py b/src/sakia/tests/technical/test_transactions_service.py new file mode 100644 index 0000000000000000000000000000000000000000..dfbdfbcdead2728acbc331b0e07ed3d840bd2f19 --- /dev/null +++ b/src/sakia/tests/technical/test_transactions_service.py @@ -0,0 +1,39 @@ +import pytest +from sakia.data.entities import Transaction + + +@pytest.mark.asyncio +async def test_send_tx_then_validate(application_with_one_connection, fake_server, bob, alice): + tx_before_send = application_with_one_connection.transactions_services[fake_server.forge.currency].transfers(bob.key.pubkey) + bob_connection = application_with_one_connection.db.connections_repo.get_one(pubkey=bob.key.pubkey) + await application_with_one_connection.documents_service.send_money(bob_connection, + bob.password, + alice.key.pubkey, 10, 0, "Test comment") + tx_after_send = application_with_one_connection.transactions_services[fake_server.forge.currency].transfers(bob.key.pubkey) + assert len(tx_before_send) + 1 == len(tx_after_send) + assert tx_after_send[-1].state is Transaction.AWAITING + fake_server.forge.forge_block() + fake_server.forge.forge_block() + fake_server.forge.forge_block() + new_blocks = fake_server.forge.blocks[-3:] + application_with_one_connection.transactions_services[fake_server.forge.currency].handle_new_blocks(new_blocks) + tx_after_parse = application_with_one_connection.transactions_services[fake_server.forge.currency].transfers(bob.key.pubkey) + assert tx_after_parse[-1].state is Transaction.VALIDATED + await fake_server.close() + + +@pytest.mark.asyncio +async def test_receive_tx(application_with_one_connection, fake_server, bob, alice): + tx_before_send = application_with_one_connection.transactions_services[fake_server.forge.currency].transfers(bob.key.pubkey) + fake_server.forge.push(alice.send_money(10, fake_server.forge.user_identities[alice.key.pubkey].sources, bob, + fake_server.forge.blocks[-1].blockUID, "Test receive")) + fake_server.forge.forge_block() + fake_server.forge.forge_block() + fake_server.forge.forge_block() + new_blocks = fake_server.forge.blocks[-3:] + application_with_one_connection.transactions_services[fake_server.forge.currency].handle_new_blocks(new_blocks) + tx_after_parse = application_with_one_connection.transactions_services[fake_server.forge.currency].transfers(bob.key.pubkey) + assert tx_after_parse[-1].state is Transaction.VALIDATED + assert len(tx_before_send) + 1 == len(tx_after_parse) + await fake_server.close() + diff --git a/src/sakia/tests/unit/data/test_transactions_repo.py b/src/sakia/tests/unit/data/test_transactions_repo.py index 8582943e037b747984ef9e944ac00366d39bf472..6b2010d5c31856c2bf6cc31f4173c15f0f537e13 100644 --- a/src/sakia/tests/unit/data/test_transactions_repo.py +++ b/src/sakia/tests/unit/data/test_transactions_repo.py @@ -1,6 +1,5 @@ from sakia.data.repositories import TransactionsRepo from sakia.data.entities import Transaction -from duniterpy.documents import BlockUID def test_add_get_drop_transaction(meta_repo): @@ -17,7 +16,7 @@ def test_add_get_drop_transaction(meta_repo): 1, "", 0, - Transaction.LOCAL)) + Transaction.TO_SEND)) transaction = transactions_repo.get_one(sha_hash="FCAD5A388AC8A811B45A9334A375585E77071AA9F6E5B6896582961A6C66F365") assert transaction.currency == "testcurrency" assert transaction.sha_hash == "FCAD5A388AC8A811B45A9334A375585E77071AA9F6E5B6896582961A6C66F365" @@ -51,7 +50,7 @@ def test_add_get_multiple_transaction(meta_repo): 2, "Test", 2, - Transaction.LOCAL)) + Transaction.TO_SEND)) transactions_repo.insert(Transaction("testcurrency", "FCAD5A388AC8A811B45A9334A375585E77071AA9F6E5B6896582961A6C66F365", 20, @@ -64,7 +63,7 @@ def test_add_get_multiple_transaction(meta_repo): 1, "", 0, - Transaction.LOCAL)) + Transaction.TO_SEND)) transactions = transactions_repo.get_all(currency="testcurrency") assert "testcurrency" in [t.currency for t in transactions] assert "7Aqw6Efa9EzE7gtsc8SveLLrM7gm6NEGoywSv4FJx6pZ" in [t.receiver for t in transactions] @@ -85,7 +84,7 @@ def test_add_update_transaction(meta_repo): 1, "", 0, - Transaction.LOCAL) + Transaction.TO_SEND) transactions_repo.insert(transaction) transaction.written_on = None transactions_repo.update(transaction)