diff --git a/tests/test_monero_wallet_full.py b/tests/test_monero_wallet_full.py index f5f6018..0cde529 100644 --- a/tests/test_monero_wallet_full.py +++ b/tests/test_monero_wallet_full.py @@ -3,16 +3,20 @@ from typing import Optional from typing_extensions import override + +from time import sleep from monero import ( MoneroWalletFull, MoneroWalletConfig, MoneroAccount, MoneroSubaddress, MoneroWallet, MoneroNetworkType, - MoneroRpcConnection, MoneroUtils, MoneroDaemonRpc + MoneroRpcConnection, MoneroUtils, MoneroDaemonRpc, + MoneroSyncResult, MoneroTxWallet ) from utils import ( TestUtils as Utils, StringUtils, AssertUtils, WalletUtils, WalletType, - MultisigSampleCodeTester + MultisigSampleCodeTester, SyncSeedTester, + SyncProgressTester, WalletEqualityUtils ) from test_monero_wallet_common import BaseTestMoneroWallet @@ -297,6 +301,259 @@ def test_create_wallet_from_keys_pybind(self, wallet: MoneroWalletFull) -> None: finally: wallet_keys.close() + # Can sync a wallet with a randomly generated seed + @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") + def test_sync_random(self, daemon: MoneroDaemonRpc) -> None: + assert daemon.is_connected(), "Not connected to daemon" + + # create test wallet + wallet: MoneroWalletFull = self._create_wallet(MoneroWalletConfig(), False) + restore_height: int = daemon.get_height() + + # test wallet's height before syncing + AssertUtils.assert_connection_equals(Utils.get_daemon_rpc_connection(), wallet.get_daemon_connection()) + assert restore_height == wallet.get_daemon_height() + assert wallet.is_connected_to_daemon() + assert wallet.is_synced() is False + assert wallet.get_height() == 1 + assert wallet.get_restore_height() == restore_height + assert wallet.get_daemon_height() == daemon.get_height() + + # sync the wallet + progress_tester: SyncProgressTester = SyncProgressTester(wallet, wallet.get_restore_height(), wallet.get_daemon_height()) + result: MoneroSyncResult = wallet.sync(progress_tester) + progress_tester.on_done(wallet.get_daemon_height()) + + # test result after syncing + wallet_gt: MoneroWalletFull = Utils.create_wallet_ground_truth(Utils.NETWORK_TYPE, wallet.get_seed(), None, restore_height) + wallet_gt.sync() + + try: + assert wallet.is_connected_to_daemon() + assert wallet.is_synced() + assert result.num_blocks_fetched == 0 + assert result.received_money is False + assert wallet.get_height() == daemon.get_height() + + # sync the wallet with default params + wallet.sync() + assert wallet.is_synced() + assert wallet.get_height() == daemon.get_height() + + # compare wallet to ground truth + WalletEqualityUtils.test_wallet_full_equality_on_chain(wallet_gt, wallet) + finally: + wallet_gt.close(True) + wallet.close() + + # attempt to sync unconnected wallet + config: MoneroWalletConfig = MoneroWalletConfig() + config.server = MoneroRpcConnection(Utils.OFFLINE_SERVER_URI) + wallet = self._create_wallet(config) + try: + wallet.sync() + raise Exception("Should have thrown exception") + except Exception as e: + e_msg: str = str(e) + assert e_msg == "Wallet is not connected to daemon", e_msg + finally: + wallet.close() + + # Can sync a wallet created from seed from the genesis + @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") + @pytest.mark.skipif(Utils.LITE_MODE, reason="LITE_MODE enabled") + def test_sync_seed_from_genesis(self, daemon: MoneroDaemonRpc, wallet: MoneroWalletFull) -> None: + self._test_sync_seed(daemon, wallet, None, None, True, False) + + # Can sync a wallet created from seed from a restore height + @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") + @pytest.mark.skipif(Utils.LITE_MODE, reason="LITE_MODE enabled") + def test_sync_seed_from_restore_height(self, daemon: MoneroDaemonRpc, wallet: MoneroWalletFull) -> None: + self._test_sync_seed(daemon, wallet, None, Utils.FIRST_RECEIVE_HEIGHT) + + # Can sync a wallet created from seed from a start height + @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") + @pytest.mark.skipif(Utils.LITE_MODE, reason="LITE_MODE enabled") + def test_sync_seed_from_start_height(self, daemon: MoneroDaemonRpc, wallet: MoneroWalletFull) -> None: + self._test_sync_seed(daemon, wallet, Utils.FIRST_RECEIVE_HEIGHT, None, False, True) + + # Can sync a wallet created from seed from a start height less than the restore height + @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") + @pytest.mark.skipif(Utils.LITE_MODE, reason="LITE_MODE enabled") + def test_sync_seed_start_height_lt_restore_height(self, daemon: MoneroDaemonRpc, wallet: MoneroWalletFull) -> None: + self._test_sync_seed(daemon, wallet, Utils.FIRST_RECEIVE_HEIGHT, Utils.FIRST_RECEIVE_HEIGHT + 3) + + # Can sync a wallet created from seed from a start height greater than the restore height + @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") + @pytest.mark.skipif(Utils.LITE_MODE, reason="LITE_MODE enabled") + def test_sync_seed_start_height_gt_restore_height(self, daemon: MoneroDaemonRpc, wallet: MoneroWalletFull) -> None: + self._test_sync_seed(daemon, wallet, Utils.FIRST_RECEIVE_HEIGHT + 3, Utils.FIRST_RECEIVE_HEIGHT) + + # Can sync a wallet created from keys + @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") + @pytest.mark.skipif(Utils.LITE_MODE, reason="LITE_MODE enabled") + def test_sync_wallet_from_keys(self, daemon: MoneroDaemonRpc, wallet: MoneroWalletFull) -> None: + # recreate test wallet from keys + path: str = Utils.get_random_wallet_path() + config: MoneroWalletConfig = MoneroWalletConfig() + config.path = path + config.primary_address = wallet.get_primary_address() + config.private_view_key = wallet.get_private_view_key() + config.private_spend_key = wallet.get_private_spend_key() + config.restore_height = Utils.FIRST_RECEIVE_HEIGHT + wallet_keys: MoneroWalletFull = self._create_wallet(config, False) + + # create ground truth wallet for comparison + wallet_gt: MoneroWalletFull = Utils.create_wallet_ground_truth(Utils.NETWORK_TYPE, Utils.SEED, None, Utils.FIRST_RECEIVE_HEIGHT) + + # test wallet and close as final step + try: + assert wallet_keys.get_seed() == wallet_gt.get_seed() + assert wallet_keys.get_primary_address() == wallet_gt.get_primary_address() + assert wallet_keys.get_private_view_key() == wallet_gt.get_private_view_key() + assert wallet_keys.get_public_view_key() == wallet_gt.get_public_view_key() + assert wallet_keys.get_private_spend_key() == wallet_gt.get_private_spend_key() + assert wallet_keys.get_public_spend_key() == wallet_gt.get_public_spend_key() + assert Utils.FIRST_RECEIVE_HEIGHT == wallet_gt.get_restore_height() + assert wallet_keys.is_connected_to_daemon() + assert wallet_keys.is_synced() is False + + # sync the wallet + progress_tester: SyncProgressTester = SyncProgressTester(wallet_keys, Utils.FIRST_RECEIVE_HEIGHT, wallet_keys.get_daemon_max_peer_height()) + result: MoneroSyncResult = wallet_keys.sync(progress_tester) + progress_tester.on_done(wallet_keys.get_daemon_height()) + + # test result after syncing + assert wallet_keys.is_synced() + assert wallet_keys.get_daemon_height() - Utils.FIRST_RECEIVE_HEIGHT == result.num_blocks_fetched + assert result.received_money is True + assert wallet_keys.get_height() == daemon.get_height() + assert wallet_keys.get_daemon_height() == daemon.get_height() + + # wallet should be fully synced so first tx happens on true restore height + txs: list[MoneroTxWallet] = wallet.get_txs() + assert len(txs) > 0 + tx: MoneroTxWallet = txs[0] + tx_height: int | None = tx.get_height() + assert tx_height is not None + assert Utils.FIRST_RECEIVE_HEIGHT == tx_height + + # compare with ground truth + WalletEqualityUtils.test_wallet_full_equality_on_chain(wallet_gt, wallet_keys) + finally: + wallet_gt.close(True) + wallet_keys.close() + + # Can start and stop syncing + # TODO test start syncing, notification of syncs happening, stop syncing, no notifications, etc + @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") + @pytest.mark.skipif(Utils.LITE_MODE, reason="LITE_MODE enabled") + def test_start_stop_syncing(self, daemon: MoneroDaemonRpc) -> None: + # test unconnected wallet + path: str = Utils.get_random_wallet_path() + config: MoneroWalletConfig = MoneroWalletConfig() + config.server = MoneroRpcConnection(Utils.OFFLINE_SERVER_URI) + config.path = path + wallet: MoneroWalletFull = self._create_wallet(config) + try: + assert len(wallet.get_seed()) > 0 + assert wallet.get_height() == 1 + assert wallet.get_balance() == 0 + wallet.start_syncing() + except Exception as e: + e_msg: str = str(e) + assert e_msg == "Wallet is not connected to daemon", e_msg + finally: + wallet.close() + + # test connecting wallet + path = Utils.get_random_wallet_path() + config = MoneroWalletConfig() + config.path = path + config.server = MoneroRpcConnection(Utils.OFFLINE_SERVER_URI) + wallet = self._create_wallet(config) + try: + assert len(wallet.get_seed()) > 0 + wallet.set_daemon_connection(daemon.get_rpc_connection()) + assert wallet.get_height() == 1 + assert wallet.is_synced() is False + assert wallet.get_balance() == 0 + chain_height: int = wallet.get_daemon_height() + wallet.set_restore_height(chain_height - 3) + wallet.start_syncing() + assert chain_height - 3 == wallet.get_restore_height() + AssertUtils.assert_connection_equals(daemon.get_rpc_connection(), wallet.get_daemon_connection()) + wallet.stop_syncing() + wallet.sync() + wallet.stop_syncing() + wallet.stop_syncing() + finally: + wallet.close() + + # test that sync starts automatically + restore_height: int = daemon.get_height() - 100 + path = Utils.get_random_wallet_path() + config = MoneroWalletConfig() + config.path = path + config.seed = Utils.SEED + config.restore_height = restore_height + wallet = self._create_wallet(config, False) + try: + # start syncing + assert wallet.get_height() == 1 + assert wallet.get_restore_height() == restore_height + assert wallet.is_synced() is False + assert wallet.get_balance() == 0 + wallet.start_syncing(Utils.SYNC_PERIOD_IN_MS) + + # pause for sync to start + sleep(1 + (Utils.SYNC_PERIOD_IN_MS / 1000)) + + # test that wallet has started syncing + assert wallet.get_height() > 1 + + # stop syncing + wallet.stop_syncing() + + # TODO monero-project: wallet.cpp m_synchronized only ever set to true, never false + finally: + wallet.close() + + # Does not interfere with other wallet notifications + @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") + @pytest.mark.skipif(Utils.LITE_MODE, reason="LITE_MODE enabled") + def test_wallets_do_not_interfere(self, daemon: MoneroDaemonRpc) -> None: + # create 2 wallets with a recent restore height + height: int = daemon.get_height() + restore_height: int = height - 5 + config: MoneroWalletConfig = MoneroWalletConfig() + config.seed = Utils.SEED + config.restore_height = restore_height + wallet1: MoneroWalletFull = self._create_wallet(config, False) + + config = MoneroWalletConfig() + config.seed = Utils.SEED + config.restore_height = restore_height + wallet2: MoneroWalletFull = self._create_wallet(config, False) + + # track notifications of each wallet + tester1: SyncProgressTester = SyncProgressTester(wallet1, restore_height, height) + tester2: SyncProgressTester = SyncProgressTester(wallet2, restore_height, height) + wallet1.add_listener(tester1) + wallet2.add_listener(tester2) + + # sync first wallet and test that 2nd is not notified + wallet1.sync() + assert tester1.is_notified + assert not tester2.is_notified + + # sync 2nd wallet and test that 1st is not notified + tester3: SyncProgressTester = SyncProgressTester(wallet1, restore_height, height) + wallet1.add_listener(tester3) + wallet2.sync() + assert tester2.is_notified + assert not tester3.is_notified + # Can create a subaddress with and without a label @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") @override @@ -453,4 +710,8 @@ def _test_multisig_sample(self, m: int, n: int) -> None: tester: MultisigSampleCodeTester = MultisigSampleCodeTester(m, wallets) tester.test() + def _test_sync_seed(self, daemon: MoneroDaemonRpc, wallet: MoneroWalletFull, start_height: Optional[int], restore_height: Optional[int], skip_gt_comparison: bool = False, test_post_sync_notifications: bool = False) -> None: + tester: SyncSeedTester = SyncSeedTester(daemon, wallet, self._create_wallet, start_height, restore_height, skip_gt_comparison, test_post_sync_notifications) + tester.test() + #endregion diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py index c9df6f8..6c5f3c8 100644 --- a/tests/utils/__init__.py +++ b/tests/utils/__init__.py @@ -28,6 +28,9 @@ from .wallet_notification_collector import WalletNotificationCollector from .submit_then_relay_tx_tester import SubmitThenRelayTxTester from .multisig_sample_code_tester import MultisigSampleCodeTester +from .wallet_sync_tester import WalletSyncTester +from .sync_progress_tester import SyncProgressTester +from .sync_seed_tester import SyncSeedTester __all__ = [ 'WalletUtils', @@ -59,5 +62,8 @@ 'ViewOnlyAndOfflineWalletTester', 'WalletNotificationCollector', 'SubmitThenRelayTxTester', - 'MultisigSampleCodeTester' + 'MultisigSampleCodeTester', + 'WalletSyncTester', + 'SyncProgressTester', + 'SyncSeedTester' ] diff --git a/tests/utils/blockchain_utils.py b/tests/utils/blockchain_utils.py index ef1b48b..ca1fcb4 100644 --- a/tests/utils/blockchain_utils.py +++ b/tests/utils/blockchain_utils.py @@ -8,8 +8,6 @@ from .test_utils import TestUtils as Utils from .mining_utils import MiningUtils from .tx_spammer import TxSpammer -from .string_utils import StringUtils - logger: logging.Logger = logging.getLogger("BlockchainUtils") diff --git a/tests/utils/sync_progress_tester.py b/tests/utils/sync_progress_tester.py new file mode 100644 index 0000000..05ad1e7 --- /dev/null +++ b/tests/utils/sync_progress_tester.py @@ -0,0 +1,105 @@ + +from typing import Optional, override + +from monero import MoneroWalletFull + +from .wallet_sync_printer import WalletSyncPrinter + + +class SyncProgressTester(WalletSyncPrinter): + """Wallet sync progress tester.""" + + wallet: MoneroWalletFull + """Test wallet instance.""" + prev_height: Optional[int] + """Previous blockchain height.""" + start_height: int + """Blockchain start height.""" + prev_end_height: int + """Previous blockchain end height.""" + prev_complete_height: Optional[int] + """Previous blockchain completed height.""" + is_done: bool + """Indicates if wallet sync is completed.""" + on_sync_progress_after_done: Optional[bool] + """Indicates that `on_sync_progress` has been called after `on_done`.""" + + @property + def is_notified(self) -> bool: + """ + Check if listener was notified. + + :returns bool: `True` if listener got notified by sync progress. + """ + return self.prev_height is not None + + def __init__(self, wallet: MoneroWalletFull, start_height: int, end_height: int) -> None: + """ + Initialize a new wallet sync progress tester. + + :param MoneroWalletFull wallet: wallet to test. + :param int start_height: wallet start height. + :param int end_height: wallet end height. + """ + super(SyncProgressTester, self).__init__() + self.wallet = wallet + assert start_height >= 0, f"Invalid start height provided: {start_height}" + assert end_height >= 0, f"Invalid end height provided: {end_height}" + self.start_height = start_height + self.prev_end_height = end_height + self.is_done = False + + self.prev_height = None + self.prev_complete_height = None + self.on_sync_progress_after_done = None + + @override + def on_sync_progress(self, height: int, start_height: int, end_height: int, percent_done: float, message: str) -> None: + super().on_sync_progress(height, start_height, end_height, percent_done, message) + + # registered wallet listeners will continue to get sync notifications after the wallet's initial sync + if self.is_done: + assert self in self.wallet.get_listeners(), "Listener has completed and is not registered so should not be called again" + self.on_sync_progress_after_done = True + + # update tester's start height if new sync session + if self.prev_complete_height is not None and start_height == self.prev_complete_height: + self.start_height = start_height + + # if sync is complete, record completion height for subsequent start heights + if int(percent_done) == 1: + self.prev_complete_height = end_height + elif self.prev_complete_height is not None: + # otherwise start height is equal to previous completion height + assert self.prev_complete_height == start_height + + assert end_height > start_height, "end height > start height" + assert self.start_height == start_height + assert end_height >= start_height + assert height < end_height + + expected_percent_done: float = (height - start_height + 1) / (end_height - start_height) + assert expected_percent_done == percent_done + if self.prev_height is None: + assert start_height == height + else: + assert height == self.prev_height + 1 + + self.prev_height = height + + def on_done(self, chain_height: int) -> None: + """ + Called once on sync progress done. + + :param int chain_height: blockchain height reached. + """ + assert self.is_done is False + self.is_done = True + if self.prev_height is None: + assert self.prev_complete_height is None + assert chain_height == self.start_height + else: + # otherwise last height is chain height - 1 + assert chain_height - 1 == self.prev_height + assert chain_height == self.prev_complete_height + diff --git a/tests/utils/sync_seed_tester.py b/tests/utils/sync_seed_tester.py new file mode 100644 index 0000000..87b6a60 --- /dev/null +++ b/tests/utils/sync_seed_tester.py @@ -0,0 +1,182 @@ +import logging + +from typing import Optional, Callable + +from time import sleep +from monero import ( + MoneroDaemonRpc, MoneroWalletFull, MoneroWalletConfig, + MoneroSyncResult, MoneroTxWallet +) +from .sync_progress_tester import SyncProgressTester +from .wallet_sync_tester import WalletSyncTester +from .test_utils import TestUtils +from .mining_utils import MiningUtils +from .wallet_equality_utils import WalletEqualityUtils + +logger: logging.Logger = logging.getLogger("SyncSeedTester") + + +class SyncSeedTester: + """Wallet sync from seed tester.""" + + __test__ = False + + daemon: MoneroDaemonRpc + """Test daemon instance.""" + wallet: MoneroWalletFull + """Test wallet instance.""" + start_height: Optional[int] + """Wallet start height.""" + restore_height: Optional[int] + """Wallet restore height.""" + skip_gt_comparison: bool + """Skip wallet ground truth comparision.""" + test_post_sync_notifications: bool + """Test post-sync wallet notifications.""" + + create_wallet: Callable[[MoneroWalletConfig, bool], MoneroWalletFull] + """Create wallet function.""" + + def __init__(self, + daemon: MoneroDaemonRpc, + wallet: MoneroWalletFull, + create_wallet: Callable[[MoneroWalletConfig, bool], MoneroWalletFull], + start_height: Optional[int], + restore_height: Optional[int], + skip_gt_comparison: bool = False, + test_post_sync_notifications: bool = False + ) -> None: + """ + Initialize a new sync seed tester. + + :param MoneroDaemonRpc daemon: daemon test instance. + :param MoneroWalletFull wallet: wallet test instance. + :param Callable[[MoneroWalletConfig, bool], MoneroWalletFull] create_wallet: wallet creation function. + :param int | None start_height: blockchain start height. + :param int | None restore_height: wallet restore height. + :param bool skip_gt_comparison: Skip wallet ground thruth verification (default `False`). + :param bool test_post_sync_notifications: Test wallet post-sync notifications (default `False`). + """ + self.daemon = daemon + self.wallet = wallet + self.create_wallet = create_wallet + self.start_height = start_height + self.restore_height = restore_height + self.skip_gt_comparison = skip_gt_comparison + self.test_post_sync_notifications = test_post_sync_notifications + + def test_notifications(self, wallet: MoneroWalletFull, start_height_expected: int, end_height_expected: int) -> None: + """ + Test wallet notifications. + + :param MoneroWalletFull wallet: wallet to test. + :param int start_height_expected: expected start height. + :param int end_height_expected: expected end height. + """ + # test wallet's height before syncing + assert wallet.is_connected_to_daemon() + assert wallet.is_synced() is False + assert wallet.get_height() == 1 + assert wallet.get_restore_height() == self.restore_height + + # register a wallet listener which tests notifications throughout the sync + wallet_sync_tester: WalletSyncTester = WalletSyncTester(wallet, start_height_expected, end_height_expected) + wallet.add_listener(wallet_sync_tester) + + # sync the wallet with a listener which tests sync notifications + progress_tester: SyncProgressTester = SyncProgressTester(wallet, start_height_expected, end_height_expected) + result: MoneroSyncResult = wallet.sync(self.start_height, progress_tester) if self.start_height is not None else wallet.sync(progress_tester) + + # test completion of the wallet and sync listeners + progress_tester.on_done(wallet.get_daemon_height()) + wallet_sync_tester.on_done(wallet.get_daemon_height()) + + # test result after syncing + assert wallet.is_synced() + assert wallet.get_daemon_height() - start_height_expected == result.num_blocks_fetched + assert result.received_money + + if wallet.get_height() != self.daemon.get_height(): + # TODO height may not be same after long sync + logger.warning(f"wallet height {wallet.get_height()} is not synced with daemon height {self.daemon.get_height()}") + + assert wallet.get_daemon_height() == self.daemon.get_height() + + wallet_txs: list[MoneroTxWallet] = wallet.get_txs() + assert len(wallet_txs) > 0 + wallet_tx: MoneroTxWallet = wallet_txs[0] + tx_height: int | None = wallet_tx.get_height() + assert tx_height is not None + + if start_height_expected > TestUtils.FIRST_RECEIVE_HEIGHT: + assert tx_height > TestUtils.FIRST_RECEIVE_HEIGHT + else: + assert tx_height == TestUtils.FIRST_RECEIVE_HEIGHT + + # sync the wallet with default params + result = wallet.sync() + assert wallet.is_synced() + assert self.daemon.get_height() == wallet.get_height() + # block might be added to chain + assert result.num_blocks_fetched == 0 or result.num_blocks_fetched == 1 + assert result.received_money is False + + # compare with ground truth + if not self.skip_gt_comparison: + wallet_gt = TestUtils.create_wallet_ground_truth(TestUtils.NETWORK_TYPE, wallet.get_seed(), self.start_height, self.restore_height) + WalletEqualityUtils.test_wallet_full_equality_on_chain(wallet_gt, wallet) + + # if testing post-sync notifications, wait for a block to be added to the chain + # then test that sync arg listener was not invoked and registered wallet listener was invoked + if self.test_post_sync_notifications: + # start automatic syncing + wallet.start_syncing(TestUtils.SYNC_PERIOD_IN_MS) + + # attempt to start mining to push the network along + MiningUtils.try_start_mining() + + try: + logger.info("Waiting for next block to test post sync notifications") + self.daemon.wait_for_next_block_header() + + # ensure wallet has time to detect new block + sleep((TestUtils.SYNC_PERIOD_IN_MS / 1000) + 3) + + # test that wallet listener's onSyncProgress() and onNewBlock() were invoked after previous completion + assert wallet_sync_tester.on_sync_progress_after_done + assert wallet_sync_tester.on_new_block_after_done + finally: + MiningUtils.try_stop_mining() + + def test(self) -> None: + """Do sync seed test.""" + # check test parameters + assert self.daemon.is_connected(), "Not connected to daemon" + if self.start_height is not None and self.restore_height is not None: + assert self.start_height <= TestUtils.FIRST_RECEIVE_HEIGHT or self.restore_height <= TestUtils.FIRST_RECEIVE_HEIGHT + + # wait for txs to clear pool + TestUtils.WALLET_TX_TRACKER.wait_for_txs_to_clear_pool(self.wallet) + + # create wallet from seed + config: MoneroWalletConfig = MoneroWalletConfig() + config.seed = TestUtils.SEED + config.restore_height = self.restore_height + wallet: MoneroWalletFull = self.create_wallet(config, False) + + # sanitize expected sync bounds + if self.restore_height is None: + self.restore_height = 0 + start_height_expected: int = self.start_height if self.start_height is not None else self.restore_height + if start_height_expected == 0: + start_height_expected = 1 + end_height_expected: int = wallet.get_daemon_max_peer_height() + + # test wallet and close as final step + wallet_gt: Optional[MoneroWalletFull] = None + try: + self.test_notifications(wallet, start_height_expected, end_height_expected) + finally: + if wallet_gt is not None: + wallet_gt.close(True) + wallet.close() diff --git a/tests/utils/test_utils.py b/tests/utils/test_utils.py index 91acd35..870afd5 100644 --- a/tests/utils/test_utils.py +++ b/tests/utils/test_utils.py @@ -16,7 +16,6 @@ from .wallet_tx_tracker import WalletTxTracker from .gen_utils import GenUtils from .string_utils import StringUtils -from .assert_utils import AssertUtils from .daemon_utils import DaemonUtils logger: logging.Logger = logging.getLogger("TestUtils") @@ -568,10 +567,10 @@ def create_wallet_ground_truth( config.restore_height = restore_height if start_height is None: - start_height = 0 + start_height = 0 if restore_height is None else restore_height gt_wallet = MoneroWalletFull.create_wallet(config) - AssertUtils.assert_equals(restore_height, gt_wallet.get_restore_height()) + assert restore_height == gt_wallet.get_restore_height() gt_wallet.sync(start_height, WalletSyncPrinter(0.25)) gt_wallet.start_syncing(cls.SYNC_PERIOD_IN_MS) diff --git a/tests/utils/wallet_equality_utils.py b/tests/utils/wallet_equality_utils.py index 707130e..a63b0bc 100644 --- a/tests/utils/wallet_equality_utils.py +++ b/tests/utils/wallet_equality_utils.py @@ -7,7 +7,8 @@ MoneroWallet, MoneroTxQuery, MoneroTransferQuery, MoneroOutputQuery, MoneroAccount, MoneroSubaddress, MoneroTxWallet, MoneroTransfer, MoneroOutputWallet, - MoneroTx, MoneroOutgoingTransfer, MoneroIncomingTransfer + MoneroTx, MoneroOutgoingTransfer, MoneroIncomingTransfer, + MoneroWalletFull ) from .gen_utils import GenUtils @@ -65,6 +66,24 @@ def test_wallet_equality_on_chain(cls, w1: MoneroWallet, w2: MoneroWallet) -> No output_query.tx_query.is_confirmed = True cls.test_output_wallets_equal_on_chain(w1.get_outputs(output_query), w2.get_outputs(output_query)) + # possible configuration: on chain xor local wallet data ("strict"), txs ordered same way? TBD + @classmethod + def test_wallet_full_equality_on_chain(cls, wallet1: MoneroWalletFull, wallet2: MoneroWalletFull) -> None: + """ + Compares two full wallets for equality using only on-chain data. + + :param MoneroWalletFull wallet1: A full wallet to compare + :param MoneroWalletFull wallet2: A full wallet to compare + """ + WalletEqualityUtils.test_wallet_equality_on_chain(wallet1, wallet2) + assert wallet1.get_network_type() == wallet2.get_network_type() + wallet1_restore_height: int = wallet1.get_restore_height() + wallet2_restore_height: int = wallet2.get_restore_height() + assert wallet1_restore_height == wallet2_restore_height, f"{wallet1_restore_height} != {wallet2_restore_height}" + AssertUtils.assert_connection_equals(wallet1.get_daemon_connection(), wallet2.get_daemon_connection()) + assert wallet1.get_seed_language() == wallet2.get_seed_language() + # TODO more pybind specific extensions + @classmethod def test_accounts_equal_on_chain(cls, accounts1: list[MoneroAccount], accounts2: list[MoneroAccount]) -> None: accounts1_size = len(accounts1) diff --git a/tests/utils/wallet_sync_tester.py b/tests/utils/wallet_sync_tester.py new file mode 100644 index 0000000..2a66c5a --- /dev/null +++ b/tests/utils/wallet_sync_tester.py @@ -0,0 +1,165 @@ +import logging + +from typing import Optional, override + +from monero import ( + MoneroOutputWallet, MoneroWalletFull, MoneroTxWallet, + MoneroTxQuery +) +from .sync_progress_tester import SyncProgressTester + +logger: logging.Logger = logging.getLogger("WalletSyncTester") + + +class WalletSyncTester(SyncProgressTester): + """Wallet sync tester.""" + + wallet_tester_prev_height: Optional[int] + """Renamed from `prev_height` to not interfere with super's `prev_height`.""" + prev_output_received: Optional[MoneroOutputWallet] + """Previous notified output received.""" + prev_output_spent: Optional[MoneroOutputWallet] + """Previous notified output spent.""" + incoming_total: int + """Total incoming amount collected.""" + outgoing_total: int + """Total outgoing amount collected.""" + on_new_block_after_done: Optional[bool] + """Indicates that `on_new_block` has been called after `on_done`.""" + prev_balance: Optional[int] + """Previous notified wallet balance.""" + prev_unlocked_balance: Optional[int] + """Previous notified wallet unlocked balance.""" + + def __init__(self, wallet: MoneroWalletFull, start_height: int, end_height: int) -> None: + """ + Initialize a new wallet sync tester. + + :param MoneroWalletFull wallet: wallet to test. + :param int start_height: blockchain start height. + :param int end_height: blockchain end height. + """ + super().__init__(wallet, start_height, end_height) + assert start_height >= 0 + assert end_height >= 0 + self.incoming_total = 0 + self.outgoing_total = 0 + + # initialize empty fields + self.wallet_tester_prev_height = None + self.prev_output_received = None + self.prev_output_spent = None + self.on_new_block_after_done = None + self.prev_balance = None + self.prev_unlocked_balance = None + + @override + def on_new_block(self, height: int) -> None: + if self.is_done: + assert self in self.wallet.get_listeners(), "Listener has completed and is not registered so should not be called again" + self.on_new_block_after_done = True + if self.wallet_tester_prev_height is not None: + assert self.wallet_tester_prev_height + 1 == height + + assert height >= self.start_height + self.wallet_tester_prev_height = height + + @override + def on_balances_changed(self, new_balance: int, new_unclocked_balance: int) -> None: + if self.prev_balance is not None: + assert new_balance != self.prev_balance or new_unclocked_balance != self.prev_unlocked_balance + self.prev_balance = new_balance + self.prev_unlocked_balance = new_unclocked_balance + + @override + def on_output_received(self, output: MoneroOutputWallet) -> None: + assert output is not None + self.prev_output_received = output + + # test output + assert output.amount is not None + assert output.account_index is not None + assert output.account_index >= 0 + assert output.subaddress_index is not None + assert output.subaddress_index >= 0 + + # test output's tx + assert output.tx is not None + assert isinstance(output.tx, MoneroTxWallet) + assert output.tx.hash is not None + assert len(output.tx.hash) == 64 + assert output.tx.version is not None + assert output.tx.version >= 0 + assert output.tx.unlock_time is not None + assert output.tx.unlock_time >= 0 + # TODO this part is failing, maybe for little differences in java data model + #assert len(output.tx.inputs) == 0 + #assert len(output.tx.outputs) == 1 + #assert output.tx.outputs[0] == output + assert len(output.tx.extra) > 0 + + # add incoming amount to running total + if output.tx.is_locked is True: + # TODO: only add if not unlocked, test unlocked received + self.incoming_total += output.amount + + @override + def on_output_spent(self, output: MoneroOutputWallet) -> None: + assert output is not None + self.prev_output_spent = output + + # test output + assert output.amount is not None + assert output.account_index is not None + assert output.account_index >= 0 + if output.subaddress_index is not None: + # TODO (monero-project): can be undefined because inputs not + # provided so one created from outgoing transfer + assert output.subaddress_index >= 0 + + # test output's tx + assert output.tx is not None + assert isinstance(output.tx, MoneroTxWallet) + assert output.tx.hash is not None + assert len(output.tx.hash) == 64 + assert output.tx.version is not None + assert output.tx.version >= 0 + assert output.tx.unlock_time is not None + assert output.tx.unlock_time >= 0 + # TODO this part is failing, maybe for little differences in java data model + #assert len(output.tx.inputs) == 1 + #assert output.tx.inputs[0] == output + #assert len(output.tx.outputs) == 0 + assert len(output.tx.extra) > 0 + + # add outgoing amount to running total + if output.tx.is_locked: + self.outgoing_total += output.amount + + @override + def on_done(self, chain_height: int) -> None: + super().on_done(chain_height) + + assert self.wallet_tester_prev_height is not None + assert self.prev_output_received is not None + assert self.prev_output_spent is not None + logger.info(f"incoming amount {self.incoming_total}, outgoing total {self.outgoing_total}") + expected_balance: int = self.incoming_total - self.outgoing_total + + # output notifications do not include pool fees or outgoing amount + pool_spend_amount: int = 0 + query: MoneroTxQuery = MoneroTxQuery() + query.in_tx_pool = True + for pool_tx in self.wallet.get_txs(query): + assert pool_tx.fee is not None + pool_spend_amount += pool_tx.get_outgoing_amount() + pool_tx.fee + + logger.debug(f"pool spend amount: {pool_spend_amount}, expected balance: {expected_balance}") + expected_balance -= pool_spend_amount + logger.debug(f"new expected balance = {expected_balance}") + + wallet_balance: int = self.wallet.get_balance() + wallet_unlocked_balance: int = self.wallet.get_unlocked_balance() + assert expected_balance == wallet_balance, f"expected balance {expected_balance} != balance {wallet_balance}" + assert self.prev_balance == wallet_balance, f"previous balance {expected_balance} != balance {wallet_balance}" + assert self.prev_unlocked_balance == wallet_unlocked_balance, f"previous unlocked balance {expected_balance} != unlocked balance {wallet_balance}"