diff --git a/tests/test_monero_wallet_common.py b/tests/test_monero_wallet_common.py index 6fe1ad7..514e169 100644 --- a/tests/test_monero_wallet_common.py +++ b/tests/test_monero_wallet_common.py @@ -27,7 +27,7 @@ WalletType, IntegrationTestUtils, ViewOnlyAndOfflineWalletTester, WalletNotificationCollector, - MiningUtils + MiningUtils, SendAndUpdateTxsTester ) logger: logging.Logger = logging.getLogger("TestMoneroWalletCommon") @@ -559,6 +559,58 @@ def test_sweep_dust(self, wallet: MoneroWallet) -> None: for tx in txs: TxUtils.test_tx_wallet(tx, ctx) + # TODO: test sending to multiple accounts + # Can update a locked tx sent from/to the same account as blocks are added to the chain + @pytest.mark.skipif(TestUtils.TEST_RELAYS is False, reason="TEST_RELAYS disabled") + @pytest.mark.skipif(TestUtils.TEST_NOTIFICATIONS is False, reason="TEST_NOTIFICATIONS disabled") + def test_update_locked_same_account(self, daemon: MoneroDaemonRpc, wallet: MoneroWallet) -> None: + config: MoneroTxConfig = MoneroTxConfig() + config.address = wallet.get_primary_address() + config.amount = TxUtils.MAX_FEE + config.account_index = 0 + config.can_split = False + config.relay = True + self._test_send_and_update_txs(daemon, wallet, config) + + # Can update split locked txs sent from/to the same account as blocks are added to the chain + @pytest.mark.skipif(TestUtils.TEST_RELAYS is False, reason="TEST_RELAYS disabled") + @pytest.mark.skipif(TestUtils.TEST_NOTIFICATIONS is False, reason="TEST_NOTIFICATIONS disabled") + @pytest.mark.skipif(TestUtils.LITE_MODE, reason="LITE_MODE enabled") + def test_update_locked_same_account_split(self, daemon: MoneroDaemonRpc, wallet: MoneroWallet) -> None: + config: MoneroTxConfig = MoneroTxConfig() + config.address = wallet.get_primary_address() + config.amount = TxUtils.MAX_FEE + config.account_index = 0 + config.can_split = True + config.relay = True + self._test_send_and_update_txs(daemon, wallet, config) + + # Can update a locked tx sent from/to different accounts as blocks are added to the chain + @pytest.mark.skipif(TestUtils.TEST_RELAYS is False, reason="TEST_RELAYS disabled") + @pytest.mark.skipif(TestUtils.TEST_NOTIFICATIONS is False, reason="TEST_NOTIFICATIONS disabled") + @pytest.mark.skipif(TestUtils.LITE_MODE, reason="LITE_MODE enabled") + def test_update_locked_different_accounts(self, daemon: MoneroDaemonRpc, wallet: MoneroWallet) -> None: + config: MoneroTxConfig = MoneroTxConfig() + config.address = wallet.get_subaddress(1, 0).address + config.amount = TxUtils.MAX_FEE + config.account_index = 0 + config.can_split = False + config.relay = True + self._test_send_and_update_txs(daemon, wallet, config) + + # Can update locked, split txs sent from/to different accounts as blocks are added to the chain + @pytest.mark.skipif(TestUtils.TEST_RELAYS is False, reason="TEST_RELAYS disabled") + @pytest.mark.skipif(TestUtils.TEST_NOTIFICATIONS is False, reason="TEST_NOTIFICATIONS disabled") + @pytest.mark.skipif(TestUtils.LITE_MODE, reason="LITE_MODE enabled") + def test_update_locked_different_accounts_split(self, daemon: MoneroDaemonRpc, wallet: MoneroWallet) -> None: + config: MoneroTxConfig = MoneroTxConfig() + config.address = wallet.get_subaddress(1, 0).address + config.amount = TxUtils.MAX_FEE + config.account_index = 0 + config.can_split = True + config.relay = True + self._test_send_and_update_txs(daemon, wallet, config) + #endregion #region Non Relays Tests @@ -3755,4 +3807,13 @@ def test_rescan_blockchain(self, wallet: MoneroWallet) -> None: #endregion + #region Utils + + @classmethod + def _test_send_and_update_txs(cls, daemon: MoneroDaemonRpc, wallet: MoneroWallet, config: MoneroTxConfig) -> None: + tester: SendAndUpdateTxsTester = SendAndUpdateTxsTester(daemon, wallet, config) + tester.test() + + #endregion + #endregion diff --git a/tests/test_monero_wallet_full.py b/tests/test_monero_wallet_full.py index da02649..cac1eb2 100644 --- a/tests/test_monero_wallet_full.py +++ b/tests/test_monero_wallet_full.py @@ -108,6 +108,18 @@ def get_test_wallet(cls) -> MoneroWalletFull: #endregion + #region Test Relays + + @pytest.mark.skipif(Utils.TEST_RELAYS is False, reason="TEST_RELAYS disabled") + @pytest.mark.skipif(Utils.TEST_NOTIFICATIONS is False, reason="TEST_NOTIFICATIONS disabled") + @pytest.mark.skipif(Utils.LITE_MODE, reason="LITE_MODE enabled") + @pytest.mark.xfail(raises=RuntimeError, reason="TODO Cannot reconcile integrals: 0 vs 1. tx wallet m_is_incoming") + @override + def test_update_locked_different_accounts_split(self, daemon: MoneroDaemonRpc, wallet: MoneroWallet) -> None: + return super().test_update_locked_different_accounts_split(daemon, wallet) + + #endregion + #region Test Non Relays # Can create a random full wallet @@ -660,10 +672,6 @@ def test_multisig_sample(self) -> None: self._test_multisig_sample(2, 3) self._test_multisig_sample(2, 4) - #endregion - - #region Disabled Tests - @pytest.mark.skipif(Utils.REGTEST, reason="Cannot retrieve accurate height by date from regtest fakechain") @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") @override @@ -676,6 +684,10 @@ def test_get_height_by_date(self, wallet: MoneroWallet): def test_get_height_by_date_regtest(self, wallet: MoneroWallet): return super().test_get_height_by_date(wallet) + #endregion + + #region Disabled Tests + @pytest.mark.skip(reason="TODO disabled because importing key images deletes corresponding incoming transfers: https://github.com/monero-project/monero/issues/5812") @override def test_import_key_images(self, wallet: MoneroWallet): diff --git a/tests/test_monero_wallet_keys.py b/tests/test_monero_wallet_keys.py index f8e5ed1..5b2638c 100644 --- a/tests/test_monero_wallet_keys.py +++ b/tests/test_monero_wallet_keys.py @@ -528,6 +528,26 @@ def test_is_multisig_needed(self, wallet: MoneroWallet) -> None: def test_rescan_spent(self, wallet: MoneroWallet) -> None: return super().test_rescan_spent(wallet) + @pytest.mark.not_supported + @override + def test_update_locked_same_account(self, daemon: MoneroDaemonRpc, wallet: MoneroWallet) -> None: + return super().test_update_locked_same_account(daemon, wallet) + + @pytest.mark.not_supported + @override + def test_update_locked_same_account_split(self, daemon: MoneroDaemonRpc, wallet: MoneroWallet) -> None: + return super().test_update_locked_same_account_split(daemon, wallet) + + @pytest.mark.not_supported + @override + def test_update_locked_different_accounts(self, daemon: MoneroDaemonRpc, wallet: MoneroWallet) -> None: + return super().test_update_locked_different_accounts(daemon, wallet) + + @pytest.mark.not_supported + @override + def test_update_locked_different_accounts_split(self, daemon: MoneroDaemonRpc, wallet: MoneroWallet) -> None: + return super().test_update_locked_different_accounts_split(daemon, wallet) + #endregion #region Tests diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py index 6c5f3c8..7f2e963 100644 --- a/tests/utils/__init__.py +++ b/tests/utils/__init__.py @@ -31,6 +31,7 @@ from .wallet_sync_tester import WalletSyncTester from .sync_progress_tester import SyncProgressTester from .sync_seed_tester import SyncSeedTester +from .send_and_update_txs_tester import SendAndUpdateTxsTester __all__ = [ 'WalletUtils', @@ -65,5 +66,6 @@ 'MultisigSampleCodeTester', 'WalletSyncTester', 'SyncProgressTester', - 'SyncSeedTester' + 'SyncSeedTester', + 'SendAndUpdateTxsTester' ] diff --git a/tests/utils/send_and_update_txs_tester.py b/tests/utils/send_and_update_txs_tester.py new file mode 100644 index 0000000..6b33724 --- /dev/null +++ b/tests/utils/send_and_update_txs_tester.py @@ -0,0 +1,212 @@ +import logging + +from time import sleep +from monero import ( + MoneroWallet, MoneroTxConfig, MoneroTxWallet, + MoneroDaemon, MoneroBlockHeader, MoneroTxQuery +) + +from .test_utils import TestUtils +from .tx_utils import TxUtils +from .mining_utils import MiningUtils +from .tx_context import TxContext + +logger: logging.Logger = logging.getLogger("SendAndUpdateTxsTester") + + +class SendAndUpdateTxsTester: + """ + Tests sending a tx with an unlock time then tracking and updating it as + blocks are added to the chain. + + TODO: test wallet accounting throughout this; dedicated method? probably. + + Allows sending to and from the same account which is an edge case where + incoming txs are occluded by their outgoing counterpart (issue #4500) + and also where it is impossible to discern which incoming output is + the tx amount and which is the change amount without wallet metadata. + """ + + daemon: MoneroDaemon + """Daemon test instance.""" + wallet: MoneroWallet + """The test wallet to send from.""" + config: MoneroTxConfig + """The send configuration to send and test.""" + num_confirmations: int + """Number of blockchain confirmations.""" + + def __init__(self, daemon: MoneroDaemon, wallet: MoneroWallet, config: MoneroTxConfig) -> None: + """ + Initialize a new send and update txs tester. + + :param MoneroDaemon daemon: daemon test instance. + :param MoneroWallet wallet: wallet test instance. + :param MoneroWalletConfig config: txs send configuration to test. + """ + self.daemon = daemon + self.wallet = wallet + self.config = config + self.num_confirmations = 0 + + def setup(self) -> None: + """Setup test wallet.""" + # wait for txs to confirm and for sufficient unlocked balance + TestUtils.WALLET_TX_TRACKER.wait_for_txs_to_clear_pool(self.wallet) + assert len(self.config.subaddress_indices) == 0 + assert self.config.account_index is not None + fee: int = TxUtils.MAX_FEE * 2 + TestUtils.WALLET_TX_TRACKER.wait_for_unlocked_balance(self.wallet, self.config.account_index, None, fee) + + def test_unlock_tx(self, tx: MoneroTxWallet, is_send_response: bool) -> None: + """ + Test tx unlock. + + :param MoneroTxWallet tx: transaction to test. + :param bool is_send_response: indicates if tx originated from send response. + """ + ctx: TxContext = TxContext() + ctx.wallet = self.wallet + ctx.config = self.config + ctx.is_send_response = is_send_response + try: + TxUtils.test_tx_wallet(tx, ctx) + except Exception as e: + logger.warning(e) + raise + + def test_out_in_pair(self, tx_out: MoneroTxWallet, tx_in: MoneroTxWallet) -> None: + """ + Test transaction out / in pair. + + :param MoneroTxWallet tx_out: outgoing transaction. + :param MoneroTxWallet tx_in: incoming transaction. + """ + assert tx_out.is_confirmed == tx_in.is_confirmed + assert tx_in.get_incoming_amount() == tx_out.get_outgoing_amount() + + def test_out_in_pairs(self, txs: list[MoneroTxWallet], is_send_response: bool) -> None: + """ + Test transactions out / in pairs. + + :param list[MoneroTxWallet] txs: transactions to test out / in pairs. + :param bool is_send_response: indicates if tx originated from send response. + """ + # for each out tx + for tx in txs: + self.test_unlock_tx(tx, is_send_response) + if tx.outgoing_transfer is not None: + continue + + tx_out: MoneroTxWallet = tx + + # find incoming counterpart + tx_in: MoneroTxWallet | None = None + for tx2 in txs: + if tx.is_incoming and tx.hash == tx2.hash: + tx_in = tx2 + break + + # test out / in pair + # TODO monero-wallet-rpc: incoming txs occluded by their outgoing counterpart #4500 + if tx_in is None: + logger.warning(f"outgoing tx {tx.hash} missing incoming counterpart (issue #4500)") + else: + self.test_out_in_pair(tx_out, tx_in) + + def wait_for_confirmations(self, sent_txs: list[MoneroTxWallet], num_confirmations_total: int) -> None: + """ + Wait for txs to confirm. + + :param list[MoneroTxWallet] sent_txs: list of sent transactions. + :param int num_confirmations_total: number of confirmed txs required. + """ + # track resulting outgoing and incoming txs as blocks are added to the chain + updated_txs: list[MoneroTxWallet] | None = None + logger.info(f"{self.num_confirmations} < {num_confirmations_total} needed confirmations") + header: MoneroBlockHeader = self.daemon.wait_for_next_block_header() + logger.info(f"*** Block {header.height} added to chain ***") + + # give wallet time to catch up, otherwise incoming tx may not appear + # TODO: this lets new block slip, okay? + sleep(TestUtils.SYNC_PERIOD_IN_MS / 1000) + + # get incoming/outgoing txs with sent hashes + tx_hashes: list[str] = [] + for sent_tx in sent_txs: + assert sent_tx.hash is not None + tx_hashes.append(sent_tx.hash) + + query: MoneroTxQuery = MoneroTxQuery() + query.hashes = tx_hashes + fetched_txs: list[MoneroTxWallet] = TxUtils.get_and_test_txs(self.wallet, query, None, True, TestUtils.REGTEST) + assert len(fetched_txs) > 0 + + # test fetched txs + self.test_out_in_pairs(fetched_txs, False) + + # merge fetched txs into updated txs and original sent txs + for fetched_tx in fetched_txs: + # merge with updated txs + if updated_txs is None: + updated_txs = fetched_txs + else: + for updated_tx in updated_txs: + if fetched_tx.hash != updated_tx.hash or fetched_tx.is_outgoing != updated_tx.is_outgoing: + continue + updated_tx.merge(fetched_tx.copy()) + if updated_tx.block is None and fetched_tx.block is not None: + # copy block for testing + updated_tx.block = fetched_tx.block.copy() + updated_tx.block.txs = [updated_tx] + + # merge with original sent txs + for sent_tx in sent_txs: + if fetched_tx.hash != sent_tx.hash or fetched_tx.is_outgoing != sent_tx.is_outgoing: + continue + # TODO: it's mergeable but tests don't account for extra info + # from send (e.g. hex) so not tested; could specify in test config + sent_tx.merge(fetched_tx.copy()) + + # test updated txs + assert updated_txs is not None + self.test_out_in_pairs(updated_txs, False) + + # update confirmations in order to exit loop + fetched_tx = fetched_txs[0] + assert fetched_tx.num_confirmations is not None + self.num_confirmations = fetched_tx.num_confirmations + + def test(self) -> None: + """Run send and update txs test.""" + self.setup() + # this test starts and stops mining, so it's wrapped in order to stop mining if anything fails + try: + # send transactions + sent_txs: list[MoneroTxWallet] = self.wallet.create_txs(self.config) + + # build test context + ctx: TxContext = TxContext() + ctx.wallet = self.wallet + ctx.config = self.config + ctx.is_send_response = True + + # test sent transactions + for tx in sent_txs: + TxUtils.test_tx_wallet(tx, ctx) + assert tx.is_confirmed is False + assert tx.in_tx_pool is True + + # attemp to start mining to push the network along + MiningUtils.try_start_mining() + + # number of confirmations to test + num_confirmations_total: int = 2 + # loop to update txs through confirmations + + while self.num_confirmations < num_confirmations_total: + self.wait_for_confirmations(sent_txs, num_confirmations_total) + + finally: + # stop mining at end of test + MiningUtils.try_stop_mining() diff --git a/tests/utils/tx_utils.py b/tests/utils/tx_utils.py index d421797..0098eba 100644 --- a/tests/utils/tx_utils.py +++ b/tests/utils/tx_utils.py @@ -980,19 +980,6 @@ def is_block_in_blocks(cls, block: MoneroBlock, blocks: set[MoneroBlock] | list[ return False - @classmethod - def log_txs(cls, txs1: list[MoneroTx] | list[MoneroTxWallet], txs2: list[MoneroTxWallet]) -> None: - num_txs1 = len(txs1) - num_txs2 = len(txs2) - assert num_txs1 == num_txs2, f"Txs size don't equal: {num_txs1} != {num_txs2}" - for i, tx1 in enumerate(txs1): - tx2 = txs2[i] - idxs: list[str] = [] - for transfer in tx2.incoming_transfers: - idxs.append(f"{transfer.account_index}:{transfer.subaddress_index}") - - logger.debug(f"BLOCK TX: {tx1.hash}, WALLET TX: {tx2.hash}, {idxs}") - @classmethod def test_get_txs_structure(cls, txs: list[MoneroTxWallet], q: Optional[MoneroTxQuery], regtest: bool) -> None: """ @@ -1036,7 +1023,6 @@ def test_get_txs_structure(cls, txs: list[MoneroTxWallet], q: Optional[MoneroTxQ if len(query.hashes) == 0: other = txs[index] if not regtest: - cls.log_txs(block.txs, txs[index:(index + len(block.txs))]) assert other.hash == tx.hash, "Txs in block are not in order" # verify tx order is self-consistent with blocks unless txs manually re-ordered by querying by hash assert other == tx