From db36cebab0d5834981f2b22ef6b72d26293825b5 Mon Sep 17 00:00:00 2001 From: everoddandeven Date: Mon, 7 Sep 2026 16:58:16 +0200 Subject: [PATCH] wallet: add light wallet implementation --- .github/workflows/test.yml | 4 +- bin/setup_test_environment.sh | 2 +- external/monero-cpp | 2 +- src/cpp/py_monero_types.h | 3 + src/cpp/wallet/py_monero_wallet_bindings.cpp | 18 + src/python/__init__.pyi | 2 + src/python/monero_wallet_light.pyi | 68 +++ tests/config/config.ini | 2 + tests/docker-compose.yml | 29 +- tests/test_monero_rpc_connection.py | 11 + tests/test_monero_utils.py | 8 +- tests/test_monero_wallet_common.py | 35 +- tests/test_monero_wallet_full.py | 27 +- tests/test_monero_wallet_keys.py | 15 - tests/test_monero_wallet_light.py | 579 +++++++++++++++++++ tests/utils/__init__.py | 4 +- tests/utils/from_multiple_tx_sender.py | 2 +- tests/utils/integration_test_utils.py | 17 +- tests/utils/monero_daemon_lws.py | 553 ++++++++++++++++++ tests/utils/sync_progress_tester.py | 15 +- tests/utils/test_utils.py | 115 +++- tests/utils/wallet_send_utils.py | 6 + tests/utils/wallet_sweeper.py | 2 + tests/utils/wallet_test_utils.py | 6 +- tests/utils/wallet_tx_tracker.py | 52 +- tests/utils/wallet_type.py | 3 + 26 files changed, 1509 insertions(+), 71 deletions(-) create mode 100644 src/python/monero_wallet_light.pyi create mode 100644 tests/test_monero_wallet_light.py create mode 100644 tests/utils/monero_daemon_lws.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d519f4b..e2deb94 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -80,7 +80,7 @@ jobs: - name: Setup test environment run: | - docker compose -f tests/docker-compose.yml up -d node_1 node_2 xmr_wallet_1 xmr_wallet_2 xmr_wallet_3 + docker compose -f tests/docker-compose.yml up -d node_1 node_2 xmr_wallet_1 xmr_wallet_2 xmr_wallet_3 xmr_lws sleep 10 - name: Reset coverage counters @@ -97,7 +97,7 @@ jobs: if: always() run: | mkdir -p container-logs - for svc in node_1 node_2 xmr_wallet_1 xmr_wallet_2 xmr_wallet_3; do + for svc in node_1 node_2 xmr_wallet_1 xmr_wallet_2 xmr_wallet_3 xmr_lws; do docker compose -f tests/docker-compose.yml logs --no-color --timestamps "$svc" > "container-logs/$svc.log" 2>&1 || true done diff --git a/bin/setup_test_environment.sh b/bin/setup_test_environment.sh index 3b23543..b7d8189 100755 --- a/bin/setup_test_environment.sh +++ b/bin/setup_test_environment.sh @@ -1,4 +1,4 @@ #!/usr/bin/env bash # start docker containers -sudo docker compose -f tests/docker-compose.yml up -d node_1 node_2 xmr_wallet_1 xmr_wallet_2 xmr_wallet_3 +sudo docker compose -f tests/docker-compose.yml up -d node_1 node_2 xmr_wallet_1 xmr_wallet_2 xmr_wallet_3 xmr_lws diff --git a/external/monero-cpp b/external/monero-cpp index 6927f7b..c4ed12f 160000 --- a/external/monero-cpp +++ b/external/monero-cpp @@ -1 +1 @@ -Subproject commit 6927f7bf52dabad2179dfe2a0a0e1a95e12290c1 +Subproject commit c4ed12f4ca354eacce5dda3577fa5923b9f166f2 diff --git a/src/cpp/py_monero_types.h b/src/cpp/py_monero_types.h index 458871b..2f0c514 100644 --- a/src/cpp/py_monero_types.h +++ b/src/cpp/py_monero_types.h @@ -77,6 +77,7 @@ PYBIND11_MAKE_OPAQUE(VectorUint64); #include "wallet/monero_wallet_rpc.h" #include "wallet/monero_wallet_keys.h" #include "wallet/monero_wallet_full.h" +#include "wallet/monero_wallet_light.h" #include "utils/py_monero_utils.h" #define MONERO_CATCH_AND_RETHROW(expr) \ @@ -156,6 +157,7 @@ struct PyMoneroTypes { py::class_> py_monero_wallet_keys; py::class_> py_monero_wallet_full; py::class_> py_monero_wallet_rpc; + py::class_> py_monero_wallet_light; py::class_ py_monero_utils; py::class_ py_gen_utils; @@ -209,6 +211,7 @@ struct PyMoneroTypes { py_monero_wallet(m, "MoneroWallet"), py_monero_wallet_keys(m, "MoneroWalletKeys"), py_monero_wallet_full(m, "MoneroWalletFull"), + py_monero_wallet_light(m, "MoneroWalletLight"), py_monero_wallet_rpc(m, "MoneroWalletRpc"), py_monero_utils(m, "MoneroUtils"), py_gen_utils(m, "GenUtils"), diff --git a/src/cpp/wallet/py_monero_wallet_bindings.cpp b/src/cpp/wallet/py_monero_wallet_bindings.cpp index 3c0e8e1..beb8f8d 100644 --- a/src/cpp/wallet/py_monero_wallet_bindings.cpp +++ b/src/cpp/wallet/py_monero_wallet_bindings.cpp @@ -1104,6 +1104,24 @@ void py_monero_bind_wallet(py::module_& m, PyMoneroTypes& t) { MONERO_CATCH_AND_RETHROW(py::bytes(self.get_cache_file_buffer())); }); + // monero_wallet_light + t.py_monero_wallet_light + .def_static("wallet_exists", [](const std::string& primary_address, const std::string& private_view_key, const std::shared_ptr& rpc) { + MONERO_CATCH_AND_RETHROW(monero_wallet_light::wallet_exists(primary_address, private_view_key, rpc)); + }, py::arg("primary_address"), py::arg("private_view_key"), py::arg("rpc"), py::call_guard()) + .def_static("wallet_exists", [](const monero_wallet_config& config, const std::shared_ptr& rpc) { + MONERO_CATCH_AND_RETHROW(monero_wallet_light::wallet_exists(config, rpc)); + }, py::arg("config"), py::arg("rpc"), py::call_guard()) + .def_static("open_wallet", [](const monero_wallet_config& config, const std::shared_ptr& rpc) { + MONERO_CATCH_AND_RETHROW(monero_wallet_light::open_wallet(config, rpc)); + }, py::arg("config"), py::arg("rpc"), py::call_guard()) + .def_static("create_wallet", [](const monero_wallet_config& config, const std::shared_ptr& rpc) { + MONERO_CATCH_AND_RETHROW(monero_wallet_light::create_wallet(config, rpc)); + }, py::arg("config"), py::arg("rpc"), py::call_guard()) + .def("get_rpc_connection", [](monero_wallet_rpc& self) { + MONERO_CATCH_AND_RETHROW(self.get_rpc_connection()); + }, py::call_guard()); + // monero_wallet_rpc t.py_monero_wallet_rpc .def(py::init&>(), py::arg("rpc_connection"), py::call_guard()) diff --git a/src/python/__init__.pyi b/src/python/__init__.pyi index a953b0e..74e0b80 100644 --- a/src/python/__init__.pyi +++ b/src/python/__init__.pyi @@ -140,6 +140,7 @@ from .monero_wallet_full import MoneroWalletFull from .monero_wallet_keys import MoneroWalletKeys from .monero_wallet_listener import MoneroWalletListener from .monero_wallet_rpc import MoneroWalletRpc +from .monero_wallet_light import MoneroWalletLight __all__ = [ @@ -226,6 +227,7 @@ __all__ = [ 'MoneroWalletKeys', 'MoneroWalletListener', 'MoneroWalletRpc', + 'MoneroWalletLight', 'SslOptions', 'SerializableStruct' ] diff --git a/src/python/monero_wallet_light.pyi b/src/python/monero_wallet_light.pyi new file mode 100644 index 0000000..9de2863 --- /dev/null +++ b/src/python/monero_wallet_light.pyi @@ -0,0 +1,68 @@ +from typing import overload + +from .monero_rpc_connection import MoneroRpcConnection +from .monero_wallet_config import MoneroWalletConfig +from .monero_wallet_keys import MoneroWalletKeys + + +class MoneroWalletLight(MoneroWalletKeys): + """ + Implements a Monero wallet using `monero-lws`_. + + .. _monero-lws: https://github.com/vtnerd/monero-lws + """ + + @staticmethod + @overload + def wallet_exists(primary_address: str, private_view_key: str, rpc: MoneroRpcConnection) -> bool: + """ + Check if a wallet exists on the server. + + :param primary_address: The primary address of the wallet. + :param private_view_key: The private view key of the wallet. + :param rpc: The RPC connection to the Monero server. + :return: True if the wallet exists, False otherwise. + """ + ... + + @staticmethod + @overload + def wallet_exists(config: MoneroWalletConfig, rpc: MoneroRpcConnection) -> bool: + """ + Check if a wallet exists on the server. + + :param config: The wallet configuration. + :param rpc: The RPC connection to the Monero server. + :return: True if the wallet exists, False otherwise. + """ + ... + + @staticmethod + def open_wallet(config: MoneroWalletConfig, rpc: MoneroRpcConnection) -> MoneroWalletLight: + """ + Open an existing light wallet with the given configuration. + + :param config: The configuration for opening the wallet. + :param rpc: The RPC connection to the Monero server. + :return: An instance of MoneroWalletLight. + """ + ... + + @staticmethod + def create_wallet(config: MoneroWalletConfig, rpc: MoneroRpcConnection) -> MoneroWalletLight: + """ + Create a new light wallet with the given configuration. + + :param config: The configuration for creating the wallet. + :param rpc: The RPC connection to the Monero server. + :return: An instance of MoneroWalletLight. + """ + ... + + def get_rpc_connection(self) -> MoneroRpcConnection | None: + """ + Get the wallet's RPC connection. + + :returns MoneroRpcConnection | None: the wallet's rpc connection. + """ + ... diff --git a/tests/config/config.ini b/tests/config/config.ini index f2ee27c..ff6c542 100644 --- a/tests/config/config.ini +++ b/tests/config/config.ini @@ -15,6 +15,8 @@ rpc_password=abc123 zmq_uri=tcp://127.0.0.1:18085 zmq_pub_uri=tcp://127.0.0.1:18086 log_level=3 +lws_uri=http://127.0.0.1:8443 +lws_admin_uri=http://127.0.0.1:8444 [wallet] name=test_wallet_1 diff --git a/tests/docker-compose.yml b/tests/docker-compose.yml index fe2536d..1ac2068 100644 --- a/tests/docker-compose.yml +++ b/tests/docker-compose.yml @@ -10,6 +10,7 @@ services: - xmr_wallet_1 - xmr_wallet_2 - xmr_wallet_3 + - xmr_lws node_1: image: lalanza808/monero:v0.18.5.1 @@ -161,9 +162,35 @@ services: - node_1 - node_2 + xmr_lws: + image: vtnerd/monero-lws:master + container_name: xmr_lws + command: > + --daemon=tcp://node_2:18085 + --sub=tcp://node_2:18086 + --log-level=4 + --webhook-ssl-verification=none + --disable-admin-auth + --rest-server=http://0.0.0.0:8443/ + --admin-rest-server=http://0.0.0.0:8444/ + --access-control-origin=* + --confirm-external-bind + --max-subaddresses=1000 + --auto-accept-creation + --regtest + volumes: + - xmr_lws_data:/data + ports: + - "8443:8443" + - "8444:8444" + depends_on: + - node_1 + - node_2 + volumes: xmr_node_1_data: xmr_node_2_data: xmr_wallet_1_data: xmr_wallet_2_data: - xmr_wallet_3_data: \ No newline at end of file + xmr_wallet_3_data: + xmr_lws_data: diff --git a/tests/test_monero_rpc_connection.py b/tests/test_monero_rpc_connection.py index 6a537ca..700e3b5 100644 --- a/tests/test_monero_rpc_connection.py +++ b/tests/test_monero_rpc_connection.py @@ -31,6 +31,12 @@ def wallet_connection(self) -> MoneroRpcConnection: """Rpc connection test instance.""" return MoneroRpcConnection(Utils.WALLET_RPC_URI, Utils.WALLET_RPC_USERNAME, Utils.WALLET_RPC_PASSWORD, timeout_ms=self.TIMEOUT_MS) + # Light wallet server rpc connection fixture + @pytest.fixture(scope="class") + def lws_connection(self) -> MoneroRpcConnection: + """Rpc connection test instance.""" + return MoneroRpcConnection(Utils.LWS_RPC_URI, timeout_ms=self.TIMEOUT_MS) + #endregion #region Tests @@ -101,6 +107,11 @@ def test_node_rpc_connection(self, node_connection: MoneroRpcConnection) -> None def test_wallet_rpc_connection(self, wallet_connection: MoneroRpcConnection) -> None: RpcConnectionUtils.test_rpc_connection(wallet_connection, Utils.WALLET_RPC_URI, True, MoneroConnectionType.IPV4) + # Test light wallet server rpc connection + @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") + def test_lws_rpc_connection(self, lws_connection: MoneroRpcConnection) -> None: + RpcConnectionUtils.test_rpc_connection(lws_connection, Utils.LWS_RPC_URI, True, MoneroConnectionType.IPV4) + # Test invalid connection @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") def test_invalid_connection(self) -> None: diff --git a/tests/test_monero_utils.py b/tests/test_monero_utils.py index 8cbd4cf..7f178b1 100644 --- a/tests/test_monero_utils.py +++ b/tests/test_monero_utils.py @@ -16,7 +16,7 @@ MoneroBlock, MoneroTxWallet, MoneroIncomingTransfer, MoneroOutputWallet, MoneroTx ) -from utils import AddressBook, KeysBook, WalletUtils, BaseTestClass, WalletErrorUtils +from utils import AddressBook, KeysBook, WalletUtils, BaseTestClass logger: logging.Logger = logging.getLogger("TestMoneroUtils") @@ -435,7 +435,8 @@ def test_payment_uri_invalid_network_type(self, config: TestMoneroUtils.Config) tx_config: MoneroTxConfig = WalletUtils.build_payment_uri_config(address) with pytest.raises(Exception) as exc_info: MoneroUtils.get_payment_uri(tx_config) - WalletErrorUtils.test_invalid_address_error(exc_info.value, address) + # get_payment_uri() wraps make_uri()'s error with context, unlike e.g. validate_address() + assert str(exc_info.value) == f"Cannot make URI from supplied parameters: wrong address: {address}" # Test deprecated standalone payment id def test_payment_uri_deprecated_payment_uri(self, config: TestMoneroUtils.Config) -> None: @@ -444,7 +445,8 @@ def test_payment_uri_deprecated_payment_uri(self, config: TestMoneroUtils.Config tx_config.payment_id = "03284e41c342f03603284e41c342f03603284e41c342f03603284e41c342f036" with pytest.raises(Exception) as exc_info: MoneroUtils.get_payment_uri(tx_config, MoneroNetworkType.TESTNET) - WalletErrorUtils.test_deprecated_payment_id_error(exc_info.value) + # get_payment_uri() wraps make_uri()'s error with context, unlike e.g. validate_address() + assert str(exc_info.value) == "Cannot make URI from supplied parameters: Standalone payment id deprecated, use integrated address instead" # Can get version def test_get_version(self) -> None: diff --git a/tests/test_monero_wallet_common.py b/tests/test_monero_wallet_common.py index fd1c6a5..8516d94 100644 --- a/tests/test_monero_wallet_common.py +++ b/tests/test_monero_wallet_common.py @@ -21,10 +21,10 @@ MoneroIntegratedAddress, MoneroCheckTx, MoneroCheckReserve, MoneroAddressBookEntry, MoneroSubmitTxResult, MoneroAccountTag, MoneroKeyImageExportResult, MoneroWalletFull, MoneroKeyImageImportResult, MoneroMessageSignatureResult, - MoneroMiningStatus, MoneroVersion, MoneroSyncResult, + MoneroMiningStatus, MoneroVersion, MoneroSyncResult, MoneroWalletLight ) from utils import ( - MultisigSampleCodeTester, TestUtils, WalletEqualityUtils, + MultisigSampleCodeTester, TestUtils, WalletEqualityUtils, BlockchainUtils, StringUtils, AssertUtils, TxContext, GenUtils, WalletUtils, WalletType, IntegrationTestUtils, ViewOnlyAndOfflineWalletTester, WalletNotificationCollector, MiningUtils, BaseTestClass, @@ -98,6 +98,8 @@ def get_test_wallet(cls) -> MoneroWallet: cls._test_wallet = TestUtils.get_wallet_rpc() elif wallet_type == WalletType.KEYS: cls._test_wallet = TestUtils.get_wallet_keys() + elif wallet_type == WalletType.LIGHT: + cls._test_wallet = TestUtils.get_wallet_light() else: raise Exception("Cannot get test wallet: No wallet type setup for tests") @@ -460,6 +462,10 @@ def test_send_to_external(self, wallet: MoneroWallet) -> None: txs: list[MoneroTxWallet] = wallet.get_txs(tx_query) assert len(txs) > 0 + if isinstance(recipient, MoneroWalletLight): + # lws doesn't report unconfirmed txs, must mine some blocks + BlockchainUtils.wait_for_blocks(5) + # test recipient balance after recipient.sync() assert amount == recipient.get_balance() @@ -903,19 +909,32 @@ def test_subaddress_lookahead(self, wallet: MoneroWallet) -> None: config: MoneroWalletConfig = MoneroWalletConfig() config.account_lookahead = 1 config.subaddress_lookahead = 100000 + subaddress_idx: int = 85000 + + if isinstance(wallet, MoneroWalletLight): + subaddress_lookahead: int = int(TestUtils.MAX_LWS_SUBADDRESSES / 2) + config.subaddress_lookahead = subaddress_lookahead + subaddress_idx = subaddress_lookahead - 1 + receiver = self._create_wallet(config) # transfer funds to subaddress with high index tx_config: MoneroTxConfig = MoneroTxConfig() tx_config.account_index = 0 dest: MoneroDestination = MoneroDestination() - dest.address = receiver.get_subaddress(0, 85000).address + dest.address = receiver.get_subaddress(0, subaddress_idx).address dest.amount = TxWalletUtils.MAX_FEE tx_config.destinations.append(dest) tx_config.relay = True wallet.create_tx(tx_config) + if isinstance(receiver, MoneroWalletLight): + # lws doesn't report unconfirmed txs, must mine some blocks + current_height: int = BlockchainUtils.wait_for_blocks(10) + while receiver.get_height() < current_height: + receiver.sync() + # observe unconfirmed funds GenUtils.wait_for(1000) receiver.sync() @@ -2914,8 +2933,8 @@ def test_get_new_key_images_from_last_import(self, wallet: MoneroWallet) -> None assert image.signature is not None and len(image.signature) > 0 # Can import key images - # TODO monero-project: importing key images can cause erasure of incoming transfers per wallet2.cpp:11957 @pytest.mark.skipif(TestUtils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") + @pytest.mark.skip(reason="TODO monero-project: importing key images can cause erasure of incoming transfers per wallet2.cpp:11957") def test_import_key_images(self, wallet: MoneroWallet) -> None: export_result: MoneroKeyImageExportResult = wallet.export_key_images() assert len(export_result.key_images) > 0, "Wallet does not have any key images run send tests" @@ -3161,7 +3180,7 @@ def test_get_payment_uri(self, wallet: MoneroWallet) -> None: # test with standalone payment id config1.payment_id = "03284e41c342f03603284e41c342f03603284e41c342f03603284e41c342f036" - with pytest.raises(Exception, match="Cannot make URI from supplied parameters"): + with pytest.raises(Exception, match="Standalone payment id deprecated, use integrated address instead"): wallet.get_payment_uri(config1) # Can start and stop mining @@ -3308,7 +3327,11 @@ def test_freeze_outputs(self, wallet: MoneroWallet) -> None: tx_config.key_image = output.key_image.hex with pytest.raises(Exception) as exc_info: wallet.sweep_output(tx_config) - assert str(exc_info.value) == "No outputs found" + raise Exception("Should have thrown error") + + if not isinstance(wallet, MoneroWalletLight): + # TODO MoneroWalletLight does not support sweeping + assert str(exc_info.value) == "No outputs found" # try to freeze empty key image with pytest.raises(Exception) as exc_info: diff --git a/tests/test_monero_wallet_full.py b/tests/test_monero_wallet_full.py index 220342e..13b3b61 100644 --- a/tests/test_monero_wallet_full.py +++ b/tests/test_monero_wallet_full.py @@ -46,31 +46,8 @@ def after_all(self) -> None: Utils.WALLET_FULL_TESTS_RUN = True @override - def _create_wallet(self, config: Optional[MoneroWalletConfig], start_syncing: bool = True) -> MoneroWalletFull: - # assign defaults - if config is None: - config = MoneroWalletConfig() - random: bool = self.is_random_wallet_config(config) - if config.path is None: - config.path = Utils.TEST_WALLETS_DIR + "/" + StringUtils.get_random_string() - if config.password is None: - config.password = Utils.WALLET_PASSWORD - if config.network_type is None: - config.network_type = Utils.NETWORK_TYPE - if config.server is None: - config.server = Utils.get_daemon_rpc_connection() - if config.restore_height is None and not random: - config.restore_height = 0 - - config.regtest = config.network_type == MoneroNetworkType.MAINNET and Utils.REGTEST - - # create wallet - wallet: MoneroWalletFull = MoneroWalletFull.create_wallet(config) - if not random: - assert config.restore_height == wallet.get_restore_height() - if start_syncing is not False and wallet.is_connected_to_daemon(): - wallet.start_syncing(Utils.SYNC_PERIOD_IN_MS) - return wallet + def _create_wallet(self, config: Optional[MoneroWalletConfig], start_syncing: bool = True): + return Utils.create_wallet_full(config, self.is_random_wallet_config(config), start_syncing) @override def _open_wallet(self, config: Optional[MoneroWalletConfig], start_syncing: bool = True) -> MoneroWalletFull: diff --git a/tests/test_monero_wallet_keys.py b/tests/test_monero_wallet_keys.py index e422b1e..108b131 100644 --- a/tests/test_monero_wallet_keys.py +++ b/tests/test_monero_wallet_keys.py @@ -118,11 +118,6 @@ def test_send(self, wallet: MoneroWallet) -> None: def test_send_with_payment_id(self, wallet: MoneroWallet) -> None: return super().test_send_with_payment_id(wallet) - @pytest.mark.not_implemented - @override - def test_decode_integrated_address(self, wallet: MoneroWallet) -> None: - return super().test_decode_integrated_address(wallet) - @pytest.mark.not_supported @override def test_send_split(self, wallet: MoneroWallet) -> None: @@ -299,11 +294,6 @@ def test_import_key_images(self, wallet: MoneroWallet) -> None: def test_set_attributes(self, wallet: MoneroWallet) -> None: return super().test_set_attributes(wallet) - @pytest.mark.not_supported - @override - def test_get_payment_uri(self, wallet: MoneroWallet) -> None: - return super().test_get_payment_uri(wallet) - @pytest.mark.not_supported @override def test_mining(self, daemon: MoneroDaemonRpc, wallet: MoneroWallet) -> None: @@ -354,11 +344,6 @@ def test_scan_txs(self, wallet: MoneroWallet) -> None: def test_get_default_fee_priority(self, wallet: MoneroWallet) -> None: return super().test_get_default_fee_priority(wallet) - @pytest.mark.not_implemented - @override - def test_sign_and_verify_messages(self, wallet: MoneroWallet) -> None: - return super().test_sign_and_verify_messages(wallet) - @pytest.mark.not_supported @override def test_freeze_outputs(self, wallet: MoneroWallet) -> None: diff --git a/tests/test_monero_wallet_light.py b/tests/test_monero_wallet_light.py new file mode 100644 index 0000000..53c1c2d --- /dev/null +++ b/tests/test_monero_wallet_light.py @@ -0,0 +1,579 @@ +import pytest +import logging + +from typing import override +from monero import ( + MoneroWalletLight, MoneroWalletConfig, MoneroWallet, MoneroWalletKeys, + MoneroDaemonRpc, MoneroRpcConnection, MoneroUtils, MoneroAccount, MoneroSyncResult, + MoneroWalletFull +) + +from utils import ( + TestUtils as Utils, WalletType, ViewOnlyAndOfflineWalletTester, + WalletErrorUtils, AssertUtils, WalletUtils, SyncProgressTester, + WalletEqualityUtils, MoneroDaemonLws +) +from test_monero_wallet_common import BaseTestMoneroWallet + +logger: logging.Logger = logging.getLogger("TestMoneroWalletLight") + + +@pytest.mark.integration +class TestMoneroWalletLight(BaseTestMoneroWallet): + """Light wallet integration tests.""" + + @classmethod + @override + def get_wallet_type(cls) -> WalletType: + return WalletType.LIGHT + + def _create_open_wallet(self, config: MoneroWalletConfig | None, create: bool = True, start_syncing: bool = True) -> MoneroWalletLight: + """Create or open a light wallet.""" + # assign defaults + if config is None: + config = MoneroWalletConfig() + if config.network_type is None: + config.network_type = Utils.NETWORK_TYPE + + wallet: MoneroWalletLight + rpc: MoneroRpcConnection = Utils.get_daemon_lws_connection() + logger.info(f"create: {create}, config: {config.serialize()}") + if create and not MoneroWalletLight.wallet_exists(config, rpc): + # create wallet + logger.info("creating wallet") + wallet = MoneroWalletLight.create_wallet(config, rpc) + logger.info(f"Created wallet {wallet.get_primary_address()}") + else: + # open wallet + logger.info("opening wallet") + wallet = MoneroWalletLight.open_wallet(config, rpc) + + if start_syncing and wallet.is_connected_to_daemon(): + wallet.sync() + wallet.start_syncing(Utils.SYNC_PERIOD_IN_MS) + + # TODO ensure wallet is synced with the daemon + + return wallet + + @pytest.fixture(scope="class") + def daemon_lws(self) -> MoneroDaemonLws: + return Utils.get_daemon_lws() + + #region Overrides + + @pytest.fixture(scope="class") + @override + def wallet(self) -> MoneroWalletLight: + """Test light wallet instance.""" + return self.get_test_wallet() + + @classmethod + @override + def get_test_wallet(cls) -> MoneroWalletLight: + return super().get_test_wallet() # type: ignore + + @override + def _open_wallet(self, config: MoneroWalletConfig | None, start_syncing: bool = True) -> MoneroWalletLight: + return self._create_open_wallet(config, False, start_syncing) + + @override + def _create_wallet(self, config: MoneroWalletConfig | None, start_syncing: bool = True) -> MoneroWalletLight: + return self._create_open_wallet(config, True, start_syncing) + + @override + def _close_wallet(self, wallet: MoneroWallet, save: bool = False) -> None: + wallet.close(save) + + @override + def _get_seed_languages(self) -> list[str]: + return MoneroWalletLight.get_seed_languages() + + #endregion + + #region Tests + + @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") + @override + def test_create_wallet_random(self) -> None: + """ + Can create a random wallet. + """ + config = MoneroWalletConfig() + wallet: MoneroWalletLight = self._create_wallet(config) + seed: str = wallet.get_seed() + + try: + MoneroUtils.validate_address(wallet.get_primary_address(), Utils.NETWORK_TYPE) + MoneroUtils.validate_private_view_key(wallet.get_private_view_key()) + MoneroUtils.validate_private_spend_key(wallet.get_private_spend_key()) + MoneroUtils.validate_mnemonic(wallet.get_seed()) + assert MoneroWallet.DEFAULT_LANGUAGE == wallet.get_seed_language() + finally: + self._close_wallet(wallet) + + # attempt to create wallet at same path + try: + config = MoneroWalletConfig() + config.seed = seed + config.network_type = Utils.NETWORK_TYPE + MoneroWalletLight.create_wallet(config, Utils.get_daemon_lws_connection()) + raise Exception("Should have thrown error") + except Exception as e: + e_msg: str = str(e) + assert "Wallet already exists" == e_msg, e_msg + + # attempt to create wallet with unknown language + try: + config = MoneroWalletConfig() + config.language = "english" + config.network_type = Utils.NETWORK_TYPE + self._create_wallet(config) + raise Exception("Should have thrown error") + except Exception as e: + e_msg: str = str(e) + assert "Unknown language: english" == e_msg, e_msg + + # Can create a light wallet from a seed + @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") + @override + def test_create_wallet_from_seed(self, wallet: MoneroWallet, test_config: BaseTestMoneroWallet.Config) -> None: + # create random wallet + config: MoneroWalletConfig = MoneroWalletConfig() + config.network_type = Utils.NETWORK_TYPE + random_wallet: MoneroWalletKeys = MoneroWalletKeys.create_wallet_random(config) + seed: str = random_wallet.get_seed() + + # save for comparison + primary_address = random_wallet.get_primary_address() + private_view_key = random_wallet.get_private_view_key() + private_spend_key = random_wallet.get_private_spend_key() + + config = MoneroWalletConfig() + config.seed = seed + + w: MoneroWalletLight = self._create_wallet(config) + + try: + assert primary_address == w.get_primary_address() + assert private_view_key == w.get_private_view_key() + assert private_spend_key == w.get_private_spend_key() + assert Utils.SEED, w.get_seed() + assert MoneroWallet.DEFAULT_LANGUAGE == w.get_seed_language() + finally: + self._close_wallet(w) + + # attempt to create wallet at same path + try: + config = MoneroWalletConfig() + config.seed = seed + config.network_type = Utils.NETWORK_TYPE + MoneroWalletLight.create_wallet(config, Utils.get_daemon_lws_connection()) + raise Exception("Should have thrown error") + except Exception as e: + e_msg: str = str(e) + assert "Wallet already exists" == e_msg, e_msg + + # Can create a light wallet from keys + @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") + def test_create_wallet_from_keys(self, daemon: MoneroDaemonRpc, wallet: MoneroWallet) -> None: + # create random wallet + config: MoneroWalletConfig = MoneroWalletConfig() + config.network_type = Utils.NETWORK_TYPE + random_wallet: MoneroWalletKeys = MoneroWalletKeys.create_wallet_random(config) + + # save for comparison + primary_address = random_wallet.get_primary_address() + private_view_key = random_wallet.get_private_view_key() + private_spend_key = random_wallet.get_private_spend_key() + + config = MoneroWalletConfig() + config.primary_address = primary_address + config.private_view_key = private_view_key + config.private_spend_key = private_spend_key + + w: MoneroWalletLight = self._create_wallet(config) + + try: + assert primary_address == w.get_primary_address() + assert private_view_key == w.get_private_view_key() + assert private_spend_key == w.get_private_spend_key() + assert Utils.SEED, w.get_seed() + assert MoneroWallet.DEFAULT_LANGUAGE == w.get_seed_language() + finally: + self._close_wallet(w) + + # attempt to create wallet at same path + try: + config = MoneroWalletConfig() + config.primary_address = primary_address + config.private_view_key = private_view_key + config.private_spend_key = private_spend_key + config.network_type = Utils.NETWORK_TYPE + MoneroWalletLight.create_wallet(config, Utils.get_daemon_lws_connection()) + raise Exception("Should have thrown error") + except Exception as e: + e_msg: str = str(e) + assert "Wallet already exists" in e_msg, e_msg + + # 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, daemon_lws: MoneroDaemonLws) -> None: + assert daemon.is_connected(), "Not connected to daemon" + + # wait for lws's own scan progress to catch up to the daemon before creating the wallet, + # otherwise the new account's start_height is assigned from lws's lagging scan height + # instead of the daemon's live height. lws's scanned/start height is the 0-indexed height + # of the last processed block, one less than daemon.get_height()'s block-count convention + daemon_lws.wait_for_scan_height(Utils.ADDRESS, Utils.PRIVATE_VIEW_KEY, daemon.get_height() - 1) + + # create test wallet + wallet: MoneroWalletLight = self._create_wallet(MoneroWalletConfig(), False) + restore_height: int = daemon.get_height() + + # test wallet's height before syncing + assert restore_height == wallet.get_daemon_height() + assert wallet.is_connected_to_daemon() + assert wallet.is_synced() is False + assert wallet.get_height() == 1 + addr_info = daemon_lws.get_address_info(wallet.get_primary_address(), wallet.get_private_view_key()) + logger.info(f"wallet address info: {addr_info}") + 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) + logger.debug(f"Sync result: {result.serialize}") + 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_equality_on_chain(wallet_gt, wallet) + finally: + wallet_gt.close(True) + wallet.close() + + # attempt to sync unconnected wallet + config: MoneroWalletConfig = MoneroWalletConfig() + config.network_type = Utils.NETWORK_TYPE + wallet = MoneroWalletLight.create_wallet(config, MoneroRpcConnection(Utils.OFFLINE_SERVER_URI)) + 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() + + @pytest.mark.skipif(Utils.LITE_MODE, reason="LITE_MODE enabled") + @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False and Utils.TEST_RELAYS is False, reason="TEST_NON_RELAYS and TEST_RELAYS disabled") + def test_view_only_and_offline_wallet_compatibility(self, wallet: MoneroWallet) -> None: + # create view only wallet + config: MoneroWalletConfig = MoneroWalletConfig() + config.primary_address = wallet.get_primary_address() + config.private_view_key = wallet.get_private_view_key() + view_only_wallet: MoneroWalletLight = self._create_open_wallet(config) + + config = MoneroWalletConfig() + 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.server = MoneroRpcConnection(Utils.OFFLINE_SERVER_URI) + config.restore_height = 0 + offline_wallet: MoneroWallet = Utils.create_wallet_full(config, False) + assert offline_wallet.is_connected_to_daemon() is False + view_only_wallet.sync() + # test tx signing with wallets + try: + tester = ViewOnlyAndOfflineWalletTester(wallet, view_only_wallet, offline_wallet) + tester.test() + finally: + self._close_wallet(view_only_wallet) + self._close_wallet(offline_wallet) + + # Can be closed + # TODO refactor test_monero_wallet_full::test_close + @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") + def test_close(self) -> None: + # create test wallet + config: MoneroWalletConfig = MoneroWalletConfig() + config.seed = Utils.SEED + wallet: MoneroWalletLight = self._create_wallet(config) + try: + wallet.sync() + assert wallet.get_height() > 1, "Wallet height is still 1" + # TODO lws stucks on blockchain height after a reorg + #assert wallet.is_synced(), "Wallet is not synced" + assert wallet.is_closed() is False + + # close wallet + wallet.close() + + assert wallet.is_closed() + + # attempt to interact with the wallet + try: + wallet.get_height() + except Exception as e: + WalletErrorUtils.test_wallet_is_closed_error(e) + + try: + wallet.get_seed() + except Exception as e: + WalletErrorUtils.test_wallet_is_closed_error(e) + + try: + wallet.sync() + except Exception as e: + WalletErrorUtils.test_wallet_is_closed_error(e) + + try: + wallet.start_syncing() + except Exception as e: + WalletErrorUtils.test_wallet_is_closed_error(e) + + try: + wallet.stop_syncing() + except Exception as e: + WalletErrorUtils.test_wallet_is_closed_error(e) + finally: + # close() is idempotent, so this is safe even if already closed above + self._close_wallet(wallet) + + # re-open the wallet + config = MoneroWalletConfig() + config.seed = Utils.SEED + + wallet = self._open_wallet(config) + try: + assert wallet.is_closed() is False + wallet.sync() + # TODO monero-lws get stuck when block reorgs occurs + #assert wallet.get_daemon_height() == wallet.get_height() + assert wallet.is_closed() is False + finally: + # close the wallet + self._close_wallet(wallet) + assert wallet.is_closed() + + # Can create a subaddress without label + @override + def test_create_subaddress(self, wallet: MoneroWallet) -> None: + # create subaddresses across accounts + accounts: list[MoneroAccount] = wallet.get_accounts() + if len(accounts) < 2: + wallet.create_account() + + accounts = wallet.get_accounts() + assert len(accounts) > 1 + account_idx: int = 0 + while account_idx < 2: + # create subaddress with no label + subaddresses = wallet.get_subaddresses(account_idx) + subaddress = wallet.create_subaddress(account_idx) + assert subaddress.label is None + WalletUtils.test_subaddress(subaddress) + subaddresses_new = wallet.get_subaddresses(account_idx) + assert len(subaddresses_new) - 1 == len(subaddresses) + AssertUtils.assert_equals(subaddress, subaddresses_new[len(subaddresses_new) - 1]) + account_idx += 1 + + #endregion + + #region Not Supported Tests + + @pytest.mark.skip(reason="monero-lws does not support syncing with the pool") + @override + def test_sync_with_pool_same_accounts(self, daemon: MoneroDaemonRpc, wallet: MoneroWallet) -> None: + return super().test_sync_with_pool_same_accounts(daemon, wallet) + + @pytest.mark.skip(reason="monero-lws does not support syncing with the pool") + @override + def test_sync_with_pool_submit_and_relay(self, daemon: MoneroDaemonRpc, wallet: MoneroWallet) -> None: + return super().test_sync_with_pool_submit_and_relay(daemon, wallet) + + @pytest.mark.skip(reason="monero-lws does not support syncing with the pool") + @override + def test_sync_with_pool_relay(self, daemon: MoneroDaemonRpc, wallet: MoneroWallet) -> None: + return super().test_sync_with_pool_relay(daemon, wallet) + + @pytest.mark.skip(reason="monero-lws does not support syncing with the pool") + @override + def test_sync_with_pool_submit_and_flush(self, daemon: MoneroDaemonRpc, wallet: MoneroWallet) -> None: + return super().test_sync_with_pool_submit_and_flush(daemon, wallet) + + @pytest.mark.skip(reason="can't create two wallet on the same monero-lws instance") + @override + def test_view_only_and_offline_wallets(self, wallet: MoneroWallet) -> None: + return super().test_view_only_and_offline_wallets(wallet) + + @pytest.mark.xfail(reason="TODO monero-lws reporting random instead of empty payment id") + @override + def test_wallet_equality_ground_truth(self, wallet: MoneroWallet) -> None: + return super().test_wallet_equality_ground_truth(wallet) + + @pytest.mark.xfail(reason="monero-lws does not allow to fetch non-wallet transactions") + @override + def test_prove_unrelayed_txs(self, daemon: MoneroDaemonRpc, wallet: MoneroWallet) -> None: + return super().test_prove_unrelayed_txs(daemon, wallet) + + @pytest.mark.not_supported + @override + def test_get_new_key_images_from_last_import(self, wallet: MoneroWallet) -> None: + return super().test_get_new_key_images_from_last_import(wallet) + + @pytest.mark.not_supported + @override + def test_set_daemon_connection(self) -> None: + return super().test_set_daemon_connection() + + @pytest.mark.not_supported + @override + def test_get_path(self) -> None: + return super().test_get_path() + + @pytest.mark.not_supported + @override + def test_get_height_by_date(self, wallet: MoneroWallet) -> None: + return super().test_get_height_by_date(wallet) + + @pytest.mark.not_supported + @override + def test_create_account_with_label(self, wallet: MoneroWallet) -> None: + return super().test_create_account_with_label(wallet) + + @pytest.mark.not_supported + @override + def test_set_account_label(self, wallet: MoneroWallet) -> None: + return super().test_set_account_label(wallet) + + @pytest.mark.not_supported + @override + def test_set_subaddress_label(self, wallet: MoneroWallet) -> None: + return super().test_set_subaddress_label(wallet) + + # monero-lws doesn't provide full on-chain data + @pytest.mark.not_supported + @override + def test_get_reserve_proof_wallet(self, wallet: MoneroWallet) -> None: + return super().test_get_reserve_proof_wallet(wallet) + + # monero-lws doesn't provide full on-chain data + @pytest.mark.not_supported + @override + def test_get_reserve_proof_account(self, wallet: MoneroWallet) -> None: + return super().test_get_reserve_proof_account(wallet) + + @pytest.mark.not_supported + @override + def test_set_tx_note(self, wallet: MoneroWallet) -> None: + return super().test_set_tx_note(wallet) + + @pytest.mark.not_supported + @override + def test_set_tx_notes(self, wallet: MoneroWallet) -> None: + return super().test_set_tx_notes(wallet) + + @pytest.mark.not_supported + @override + def test_address_book(self, wallet: MoneroWallet) -> None: + return super().test_address_book(wallet) + + @pytest.mark.not_supported + @override + def test_set_attributes(self, wallet: MoneroWallet) -> None: + return super().test_set_attributes(wallet) + + @pytest.mark.not_supported + @override + def test_mining(self, daemon: MoneroDaemonRpc, wallet: MoneroWallet) -> None: + return super().test_mining(daemon, wallet) + + @pytest.mark.not_supported + @override + def test_change_password(self) -> None: + return super().test_change_password() + + @pytest.mark.not_supported + @override + def test_save_and_close(self) -> None: + return super().test_save_and_close() + + @pytest.mark.not_supported + @override + def test_account_tags(self, wallet: MoneroWallet) -> None: + return super().test_account_tags(wallet) + + @pytest.mark.not_supported + @override + def test_rescan_spent(self, wallet: MoneroWallet) -> None: + return super().test_rescan_spent(wallet) + + @pytest.mark.not_supported + @override + def test_sweep_dust(self, wallet: MoneroWallet) -> None: + return super().test_sweep_dust(wallet) + + @pytest.mark.not_supported + @override + def test_sweep_dust_no_relay(self, wallet: MoneroWallet) -> None: + return super().test_sweep_dust_no_relay(wallet) + + @pytest.mark.not_supported + @override + def test_input_key_images(self, wallet: MoneroWallet) -> None: + return super().test_input_key_images(wallet) + + @pytest.mark.not_supported + @override + def test_check_spend_proof(self, wallet: MoneroWallet) -> None: + return super().test_check_spend_proof(wallet) + + @pytest.mark.not_supported + @override + def test_check_tx_proof(self, wallet: MoneroWallet) -> None: + return super().test_check_tx_proof(wallet) + + @pytest.mark.not_supported + @override + def test_import_outputs(self, wallet: MoneroWallet) -> None: + return super().test_import_outputs(wallet) + + #endregion + + #region Sweep Tests + # kept last + + @pytest.mark.skipif(Utils.TEST_RELAYS is False, reason="TEST_RELAYS disabled") + @override + def test_sweep_outputs(self, wallet: MoneroWallet) -> None: + return super().test_sweep_outputs(wallet) + + @pytest.mark.skipif(Utils.TEST_RESETS is False, reason="TEST_RESETS disabled") + @override + def test_sweep_wallet_by_accounts(self, wallet: MoneroWallet) -> None: + return super().test_sweep_wallet_by_accounts(wallet) + + @pytest.mark.skipif(Utils.TEST_RESETS is False, reason="TEST_RESETS disabled") + @override + def test_sweep_wallet_by_subaddresses(self, wallet: MoneroWallet) -> None: + return super().test_sweep_wallet_by_subaddresses(wallet) + + #endregion diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py index 3711aa3..75c5c2e 100644 --- a/tests/utils/__init__.py +++ b/tests/utils/__init__.py @@ -53,6 +53,7 @@ from .wallet_send_utils import WalletSendUtils from .wallet_test_utils import WalletTestUtils from .wallet_error_utils import WalletErrorUtils +from .monero_daemon_lws import MoneroDaemonLws __all__ = [ 'WalletUtils', @@ -101,5 +102,6 @@ 'WalletSendUtils', 'WalletTestUtils', 'TxsStructureTester', - 'DaemonNotificationCollector' + 'DaemonNotificationCollector', + 'MoneroDaemonLws' ] diff --git a/tests/utils/from_multiple_tx_sender.py b/tests/utils/from_multiple_tx_sender.py index 3560e81..ebfb70f 100644 --- a/tests/utils/from_multiple_tx_sender.py +++ b/tests/utils/from_multiple_tx_sender.py @@ -75,7 +75,7 @@ def _get_src_account(self) -> MoneroAccount: if num_subaddress_balances >= self.NUM_SUBADDRESSES + 1: has_balance = True - if len(unlocked_subaddresses) >= self.NUM_SUBADDRESSES + 1: + if len(unlocked_subaddresses) > self.NUM_SUBADDRESSES + 1: src_account = account break diff --git a/tests/utils/integration_test_utils.py b/tests/utils/integration_test_utils.py index 863d50c..4d581ed 100644 --- a/tests/utils/integration_test_utils.py +++ b/tests/utils/integration_test_utils.py @@ -41,9 +41,15 @@ def setup(cls, wallet_type: WalletType) -> None: elif wallet_type == WalletType.RPC: wallet = TestUtils.get_wallet_rpc() type_str = "RPC" + elif wallet_type == WalletType.LIGHT: + wallet = TestUtils.get_wallet_light() + type_str = "LIGHT" else: - raise ValueError("Only RPC and FULL wallet are supported for integration tests") + raise ValueError("Only RPC, FULL, and LIGHT wallets are supported for integration tests") + # sync before checking for pre-existing txs: MoneroWalletLight has no local persistent + # storage, so its cache (and get_txs()) is empty until synced, even for an already-funded address + wallet.sync() wallet_txs: list[MoneroTxWallet] = wallet.get_txs() num_wallet_txs: int = len(wallet_txs) # fund wallet with mined coins and wait for unlocked balance @@ -52,11 +58,14 @@ def setup(cls, wallet_type: WalletType) -> None: # setup first receive height tx: MoneroTxWallet = txs[0] if num_wallet_txs == 0 else wallet_txs[0] tx_height: int | None = tx.get_height() - assert tx_height is not None + assert tx_height is not None, "Could not get test wallet first receive height" TestUtils.FIRST_RECEIVE_HEIGHT = tx_height - logger.debug(f"Test wallet first receive height: {tx_height}") + logger.debug(f"FIRST_RECEIVE_HEIGHT = {tx_height}") - if num_wallet_txs < len(txs): + if num_wallet_txs == 0 and TestUtils.REGTEST: + # needed for correct m_num_suggested_confirmations estimate in light wallet + MiningUtils.generate_blocks(wallet.get_primary_address(), 1) + wallet.sync() logger.info(f"Funded test wallet {type_str}") @classmethod diff --git a/tests/utils/monero_daemon_lws.py b/tests/utils/monero_daemon_lws.py new file mode 100644 index 0000000..441ab1f --- /dev/null +++ b/tests/utils/monero_daemon_lws.py @@ -0,0 +1,553 @@ +import logging +import time +from typing import Any +from monero import MoneroRpcConnection + +logger: logging.Logger = logging.getLogger("MoneroDaemonLws") + + +class MoneroDaemonLws: + """Thin client for the monero-lws REST API, both the client (wallet) surface and the + admin surface. + + Client: `/login`, `/get_address_info`, `/get_address_txs`, `/get_random_outs`, + `/get_subaddrs`, `/get_unspent_outs`, `/get_version`, `/import_wallet_request`, + `/provision_subaddrs`, `/submit_raw_tx`, `/upsert_subaddrs`, and the undocumented + `/daemon_status` convenience endpoint. `MoneroWalletLight` exercises this same surface + internally; this exists to inspect the raw server responses directly (e.g. to correlate + `scanned_block_height`/`start_height` against the daemon's own height during debugging). + `/feed` (websocket upgrade) and `/get_tree_path` (fcmp++ tree signing) are out of scope. + + Admin: `/accept_requests`, `/add_account`, `/list_accounts`, `/list_requests`, + `/modify_account_status`, `/reject_requests`, `/rescan`, `/validate`, `/webhook_add`, + `/webhook_delete`, `/webhook_delete_uuid`, and `/webhook_list`. + """ + + rpc: MoneroRpcConnection | None + """Rpc connection to the monero-lws client (wallet) REST server, or `None` if this + instance was constructed without a client `uri` (admin-only usage).""" + admin_rpc: MoneroRpcConnection | None + """Rpc connection to the monero-lws admin REST server, or `None` if this instance was + constructed without an `admin_uri` (client-only usage). + + monero-lws serves the client and admin surfaces on separate listeners (`--rest-server` + and `--admin-rest-server`), which can be different ports entirely, or the same host:port + merged by path prefix -- either way they are logically and physically distinct + connections, so each gets its own `MoneroRpcConnection` rather than sharing one.""" + auth: str | None + """Admin auth key from `monero-lws-admin create_admin`, or `None` if the server was + started with `--disable-admin-auth`.""" + + def __init__(self, uri: str | None = None, admin_uri: str | None = None, auth: str | None = None) -> None: + """Initialize a new monero-lws client, admin, or combined client. + + At least one of `uri`/`admin_uri` must be given. Calling a client method without a + `uri`, or an admin method without an `admin_uri`, raises `RuntimeError`. + + :param str | None uri: client (wallet) REST server uri, e.g. `http://127.0.0.1:8443`. + :param str | None admin_uri: admin REST server uri, e.g. `http://127.0.0.1:8444`. + :param str | None auth: admin auth key, if the server requires one. + """ + if uri is None and admin_uri is None: + raise ValueError("Must provide at least one of uri or admin_uri") + + # do not call MoneroRpcConnection.check_connection() here: it probes with a binary + # get_blocks_by_height request meant for a monerod daemon connection, which monero-lws + # doesn't implement (404, tolerated) -- but reusing that same keep-alive connection + # afterwards for a JSON request (e.g. get_address_info) then arrives at the server with + # a corrupted/truncated body ("missing required field: address"). A connection that's + # genuinely unreachable will fail clearly on the first real request anyway. + self.rpc = MoneroRpcConnection(uri) if uri is not None else None + self.admin_rpc = MoneroRpcConnection(admin_uri) if admin_uri is not None else None + + self.auth = auth + + def _request(self, endpoint: str, params: dict[str, Any] | None = None) -> Any: + """Send an admin request and return the parsed JSON response. + + :param str endpoint: admin endpoint name (without the leading `/`). + :param dict[str, Any] | None params: endpoint-specific `params` object, if any. + :returns Any: the parsed JSON response. + :raises RuntimeError: if this instance was constructed without an `admin_uri`. + """ + if self.admin_rpc is None: + raise RuntimeError("MoneroDaemonLws was constructed without admin_uri; cannot call admin endpoints") + + body: dict[str, Any] = {} + if params is not None: + body["params"] = params + if self.auth is not None: + body["auth"] = self.auth + logger.debug(f"POST (admin) /{endpoint} {body}") + return self.admin_rpc.send_path_request(endpoint, body) + + def _client_request(self, endpoint: str, body: dict[str, Any]) -> Any: + """Send a client (wallet) request and return the parsed JSON response. + + Unlike the admin surface, client endpoints take the request fields directly at the + top level of the body -- there is no `params`/`auth` wrapper. + + :param str endpoint: client endpoint name (without the leading `/`). + :param dict[str, Any] body: the full request body. + :returns Any: the parsed JSON response. + :raises RuntimeError: if this instance was constructed without a client `uri`. + """ + if self.rpc is None: + raise RuntimeError("MoneroDaemonLws was constructed without uri; cannot call client endpoints") + + logger.debug(f"POST /{endpoint} {body}") + return self.rpc.send_path_request(endpoint, body) + + @staticmethod + def _address_meta(lookahead: tuple[int, int]) -> dict[str, int]: + """Build an `address_meta` (subaddress lookahead) object. + + :param tuple[int, int] lookahead: `(maj_i, min_i)`. + :returns dict[str, int]: `{"maj_i": ..., "min_i": ...}`. + """ + return {"maj_i": lookahead[0], "min_i": lookahead[1]} + + #region Client API + + def daemon_status(self) -> dict[str, Any]: + """Get the underlying daemon's connection/sync state, as seen by monero-lws. + + Undocumented convenience endpoint (mentioned in `wallet.yaml`'s description but not + given its own path entry). + + :returns dict[str, Any]: `state`, `outgoing_connections_count`, + `incoming_connections_count`, `height`, `target_height`, `network`. + """ + return self._client_request("daemon_status", {}) + + def login( + self, + address: str, + view_key: str, + create_account: bool = False, + generated_locally: bool = False, + lookahead: tuple[int, int] | None = None, + ) -> dict[str, Any]: + """Create and/or check an account's status. + + :param str address: base58 primary address. + :param str view_key: hex-encoded private view key for `address`. + :param bool create_account: attempt account creation if it doesn't already exist. + :param bool generated_locally: `True` if this is a brand new (not restored) wallet. + :param tuple[int, int] | None lookahead: desired `(maj_i, min_i)` lookahead for a + newly created account. + :returns dict[str, Any]: `new_address`, `generated_locally`, `start_height`, `lookahead`. + """ + body: dict[str, Any] = { + "address": address, + "view_key": view_key, + "create_account": create_account, + "generated_locally": generated_locally, + } + if lookahead is not None: + body["lookahead"] = self._address_meta(lookahead) + return self._client_request("login", body) + + def get_address_info(self, address: str, view_key: str) -> dict[str, Any]: + """Get the minimal information needed to calculate a wallet's balance. + + :param str address: base58 primary address. + :param str view_key: hex-encoded private view key for `address`. + :returns dict[str, Any]: `locked_funds`, `total_received`, `total_sent`, + `scanned_height`, `scanned_block_height`, `start_height`, `transaction_height`, + `blockchain_height`, `spent_outputs`, `lookahead`, `lookahead_failure`, `rates`. + """ + return self._client_request("get_address_info", {"address": address, "view_key": view_key}) + + def wait_for_scan_height( + self, + address: str, + view_key: str, + height: int, + timeout_seconds: float = 60.0, + poll_interval_seconds: float = 2.0, + ) -> int: + """Poll `get_address_info` for an already-registered account until its scan progress + reaches `height`. + + monero-lws scans blocks asynchronously in the background, so an account's + `scanned_height` can lag behind the daemon's live height for a short while after new + blocks appear. A newly created account's own `start_height` is assigned from the + server's current scan progress at that instant, so callers that need a new account's + `start_height` to match a specific daemon height should wait for an already-registered + account to catch up to that height first, before creating the new one. + + :param str address: base58 primary address of an already-registered account. + :param str view_key: hex-encoded private view key for `address`. + :param int height: block height to wait for. + :param float timeout_seconds: give up and raise after this many seconds. + :param float poll_interval_seconds: delay between polls. + :returns int: the reached `scanned_height` (>= `height`). + :raises TimeoutError: if `height` isn't reached within `timeout_seconds`. + """ + deadline = time.monotonic() + timeout_seconds + scanned_height = 0 + while True: + scanned_height = self.get_address_info(address, view_key)["scanned_height"] + if scanned_height >= height: + return scanned_height + if time.monotonic() >= deadline: + raise TimeoutError( + f"monero-lws scan did not reach height {height} within {timeout_seconds}s " + f"(stuck at {scanned_height})" + ) + time.sleep(poll_interval_seconds) + + def get_address_txs( + self, + address: str, + view_key: str, + since_tx_id: int | None = None, + since_tx_block_hash: str | None = None, + ) -> dict[str, Any]: + """Get transaction history. + + :param str address: base58 primary address. + :param str view_key: hex-encoded private view key for `address`. + :param int | None since_tx_id: only return transactions newer than this known tx id. + :param str | None since_tx_block_hash: block hash of the `since_tx_id` transaction, + required if `since_tx_id` is given (guards against a reorg invalidating it). + :returns dict[str, Any]: `total_received`, `scanned_height`, `scanned_block_height`, + `start_height`, `blockchain_height`, `transactions`, `lookahead`, + `lookahead_failure`, `since_tx_id`. + """ + body: dict[str, Any] = {"address": address, "view_key": view_key} + if since_tx_id is not None: + body["since_tx_id"] = since_tx_id + if since_tx_block_hash is not None: + body["since_tx_block_hash"] = since_tx_block_hash + return self._client_request("get_address_txs", body) + + def get_random_outs(self, count: int, amounts: list[str]) -> dict[str, Any]: + """Get server-selected decoy outputs for ring signatures. + + :param int count: mixin (number of decoys per amount). + :param list[str] amounts: XMR amounts (as decimal strings) that need decoys; `"0"` + for RingCT outputs. + :returns dict[str, Any]: `{"amount_outs": [...]}`. + """ + return self._client_request("get_random_outs", {"count": count, "amounts": amounts}) + + def get_subaddrs(self, address: str, view_key: str) -> dict[str, Any]: + """Get all subaddresses provisioned for a wallet. + + :param str address: base58 primary address. + :param str view_key: hex-encoded private view key for `address`. + :returns dict[str, Any]: `all_subaddrs`, `max_subaddrs`. + """ + return self._client_request("get_subaddrs", {"address": address, "view_key": view_key}) + + def get_unspent_outs( + self, + address: str, + view_key: str, + amount: int, + mixin: int, + use_dust: bool, + dust_threshold: str | None = None, + ) -> dict[str, Any]: + """Get outputs available for spending (client must determine what's already spent). + + :param str address: base58 primary address. + :param str view_key: hex-encoded private view key for `address`. + :param int amount: XMR amount intended to be sent. + :param int mixin: minimum mixin for source outputs. + :param bool use_dust: return all available outputs, including dust. + :param str | None dust_threshold: ignore outputs below this amount (decimal string). + :returns dict[str, Any]: `per_byte_fee`, `fee_mask`, `amount`, `outputs`, `fees` + (priority-ordered fee-per-byte estimates, lowest first). + """ + body: dict[str, Any] = { + "address": address, + "view_key": view_key, + "amount": amount, + "mixin": mixin, + "use_dust": use_dust, + } + if dust_threshold is not None: + body["dust_threshold"] = dust_threshold + return self._client_request("get_unspent_outs", body) + + def get_version(self) -> dict[str, Any]: + """Get basic information about the monero-lws server itself. + + :returns dict[str, Any]: `server_type`, `last_git_commit_hash`, `last_commit_date`, + `monero_version_full`, `blockchain_height`, `api`, `max_subaddresses`, `network`, + `testnet`. + """ + return self._client_request("get_version", {}) + + def import_wallet_request( + self, + address: str, + view_key: str, + from_height: int | None = None, + lookahead: tuple[int, int] | None = None, + ) -> dict[str, Any]: + """Request a rescan from an earlier height, with optional subaddress lookahead. + + :param str address: base58 primary address. + :param str view_key: hex-encoded private view key for `address`. + :param int | None from_height: height to rescan from (server assumes `0` if omitted). + :param tuple[int, int] | None lookahead: desired `(maj_i, min_i)` lookahead for the + rescan (server assumes `(0, 0)` if omitted). + :returns dict[str, Any]: `payment_address`, `payment_id`, `import_fee`, `new_request`, + `request_fulfilled`, `status`, `lookahead`. + """ + body: dict[str, Any] = {"address": address, "view_key": view_key} + if from_height is not None: + body["from_height"] = from_height + if lookahead is not None: + body["lookahead"] = self._address_meta(lookahead) + return self._client_request("import_wallet_request", body) + + def provision_subaddrs( + self, + address: str, + view_key: str, + maj_i: int | None = None, + min_i: int | None = None, + n_maj: int | None = None, + n_min: int | None = None, + get_all: bool | None = None, + ) -> dict[str, Any]: + """Request new subaddress ranges be provisioned, starting at the given lower bounds. + + :param str address: base58 primary address. + :param str view_key: hex-encoded private view key for `address`. + :param int | None maj_i: major index lower bound (server default `0`). + :param int | None min_i: minor index lower bound (server default `0`). + :param int | None n_maj: number of major indices to provision. + :param int | None n_min: number of minor indices to provision (per major). + :param bool | None get_all: include all (not just newly provisioned) subaddresses + in the response (server default `True`). + :returns dict[str, Any]: `new_subaddrs`, `all_subaddrs`. + """ + body: dict[str, Any] = {"address": address, "view_key": view_key} + if maj_i is not None: + body["maj_i"] = maj_i + if min_i is not None: + body["min_i"] = min_i + if n_maj is not None: + body["n_maj"] = n_maj + if n_min is not None: + body["n_min"] = n_min + if get_all is not None: + body["get_all"] = get_all + return self._client_request("provision_subaddrs", body) + + def submit_raw_tx(self, tx_hex: str) -> str: + """Relay a raw transaction to the Monero network. + + :param str tx_hex: hex-encoded raw transaction. + :returns str: the daemon's relay status (typically `"OK"`). + """ + result = self._client_request("submit_raw_tx", {"tx": tx_hex}) + return result["status"] + + def upsert_subaddrs( + self, + address: str, + view_key: str, + subaddrs: dict[str, list[list[int]]], + get_all: bool | None = None, + ) -> dict[str, Any]: + """Upsert subaddresses at specific major/minor indexes (idempotent). + + :param str address: base58 primary address. + :param str view_key: hex-encoded private view key for `address`. + :param dict[str, list[list[int]]] subaddrs: major index (as a string key) mapped to + a list of `[min, max]` inclusive minor-index ranges. + :param bool | None get_all: include all (not just upserted) subaddresses in the + response (server default `True`). + :returns dict[str, Any]: `new_subaddrs`, `all_subaddrs`. + """ + body: dict[str, Any] = {"address": address, "view_key": view_key, "subaddrs": subaddrs} + if get_all is not None: + body["get_all"] = get_all + return self._client_request("upsert_subaddrs", body) + + #endregion + + #region Account Administration + + def accept_requests(self, request_type: str, addresses: list[str]) -> list[str]: + """Accept pending create or import account requests. + + :param str request_type: `"create"` or `"import"`. + :param list[str] addresses: base58 addresses to accept. + :returns list[str]: addresses that were updated. + """ + result = self._request("accept_requests", {"type": request_type, "addresses": addresses}) + return result["updated"] + + def reject_requests(self, request_type: str, addresses: list[str]) -> list[str]: + """Reject pending create or import account requests. + + :param str request_type: `"create"` or `"import"`. + :param list[str] addresses: base58 addresses to reject. + :returns list[str]: addresses that were updated. + """ + result = self._request("reject_requests", {"type": request_type, "addresses": addresses}) + return result["updated"] + + def add_account(self, address: str, view_key: str) -> list[str]: + """Add a new account directly, bypassing the `/login` create-request flow. + + :param str address: base58 primary address. + :param str view_key: hex-encoded private view key for `address`. + :returns list[str]: addresses that were updated. + """ + result = self._request("add_account", {"address": address, "key": view_key}) + return result["updated"] + + def list_accounts(self) -> dict[str, list[dict[str, Any]]]: + """List all accounts by state. + + :returns dict[str, list[dict]]: `{"active": [...], "inactive": [...], "hidden": [...]}`, + each entry an object with `address`, `scan_height`, and `access_time`. + """ + return self._request("list_accounts") + + def list_requests(self) -> dict[str, list[dict[str, Any]]]: + """List all pending create and import account requests. + + :returns dict[str, list[dict]]: `{"create": [...], "import": [...]}`, each entry + an object with `address` and `start_height`. + """ + return self._request("list_requests") + + def modify_account_status(self, status: str, addresses: list[str]) -> list[str]: + """Move account(s) to another state. + + :param str status: `"active"`, `"inactive"`, or `"hidden"`. + :param list[str] addresses: base58 addresses to modify. + :returns list[str]: addresses that were updated. + """ + result = self._request("modify_account_status", {"status": status, "addresses": addresses}) + return result["updated"] + + def rescan(self, height: int, addresses: list[str]) -> list[str]: + """Force account(s) to rescan from a specific block height. + + :param int height: block height to rescan from. + :param list[str] addresses: base58 addresses to rescan. + :returns list[str]: addresses that were updated. + """ + result = self._request("rescan", {"height": height, "addresses": addresses}) + return result["updated"] + + def validate(self, spend_public_hex: str, view_public_hex: str, view_key_hex: str) -> str: + """Validate that a spend public key, view public key, and view secret key are consistent, + and derive the corresponding address. + + :param str spend_public_hex: hex-encoded public spend key. + :param str view_public_hex: hex-encoded public view key. + :param str view_key_hex: hex-encoded private view key. + :returns str: the base58 address derived from the given keys. + :raises RuntimeError: if the keys are not valid/consistent; identifies the offending field. + """ + result = self._request("validate", { + "spend_public_hex": spend_public_hex, + "view_public_hex": view_public_hex, + "view_key_hex": view_key_hex, + }) + if "error" in result: + error = result["error"] + raise RuntimeError(f"{error['field']}: {error['details']}") + return result["address"] + + #endregion + + #region Webhook Administration + + def webhook_add_tx_confirmation( + self, + url: str, + address: str, + payment_id: str | None = None, + token: str | None = None, + confirmations: int | None = None, + ) -> dict[str, Any]: + """Register a webhook fired when a transaction to `address` is confirmed. + + :param str url: destination URL, or `"zmq"` to publish over ZMQ PUB/SUB only. + :param str address: base58 address to watch. + :param str | None payment_id: optional 8-byte hex payment ID filter. + :param str | None token: optional token echoed back in the webhook payload. + :param int | None confirmations: confirmations required before firing (server default 1). + :returns dict[str, Any]: webhook-value with `event_id`, `payment_id`, `token`, + `confirmations`, and `url`. + """ + params: dict[str, Any] = {"type": "tx-confirmation", "url": url, "address": address} + if payment_id is not None: + params["payment_id"] = payment_id + if token is not None: + params["token"] = token + if confirmations is not None: + params["confirmations"] = confirmations + return self._request("webhook_add", params) + + def webhook_add_tx_spend( + self, + url: str, + address: str, + payment_id: str | None = None, + token: str | None = None, + ) -> dict[str, Any]: + """Register a webhook fired when an output belonging to `address` is spent. + + :param str url: destination URL, or `"zmq"` to publish over ZMQ PUB/SUB only. + :param str address: base58 address to watch. + :param str | None payment_id: optional 8-byte hex payment ID filter. + :param str | None token: optional token echoed back in the webhook payload. + :returns dict[str, Any]: webhook-value with `event_id`, `payment_id`, `token`, + `confirmations`, and `url`. + """ + params: dict[str, Any] = {"type": "tx-spend", "url": url, "address": address} + if payment_id is not None: + params["payment_id"] = payment_id + if token is not None: + params["token"] = token + return self._request("webhook_add", params) + + def webhook_add_new_account(self, url: str, token: str | None = None) -> dict[str, Any]: + """Register a webhook fired whenever a new account is created. + + :param str url: destination URL, or `"zmq"` to publish over ZMQ PUB/SUB only. + :param str | None token: optional token echoed back in the webhook payload. + :returns dict[str, Any]: webhook-value with `event_id`, `payment_id`, `token`, + `confirmations`, and `url`. + """ + params: dict[str, Any] = {"type": "new-account", "url": url} + if token is not None: + params["token"] = token + return self._request("webhook_add", params) + + def webhook_delete(self, addresses: list[str]) -> None: + """Delete webhooks associated with the given address(es). + + :param list[str] addresses: base58 addresses whose webhooks should be removed. + """ + self._request("webhook_delete", {"addresses": addresses}) + + def webhook_delete_uuid(self, event_ids: list[str]) -> None: + """Delete webhooks by event UUID. + + :param list[str] event_ids: 16-byte hex event UUIDs to remove. + """ + self._request("webhook_delete_uuid", {"event_ids": event_ids}) + + def webhook_list(self) -> dict[str, Any]: + """List all registered webhooks. + + :returns dict[str, Any]: `{"webhooks": {...}}`. + """ + return self._request("webhook_list") + + #endregion diff --git a/tests/utils/sync_progress_tester.py b/tests/utils/sync_progress_tester.py index 536e6d0..220f897 100644 --- a/tests/utils/sync_progress_tester.py +++ b/tests/utils/sync_progress_tester.py @@ -1,15 +1,17 @@ - +import logging from typing import Optional, override -from monero import MoneroWalletFull +from monero import MoneroWallet from .wallet_sync_printer import WalletSyncPrinter +logger: logging.Logger = logging.getLogger("SyncProgressTester") + class SyncProgressTester(WalletSyncPrinter): """Wallet sync progress tester.""" - wallet: MoneroWalletFull + wallet: MoneroWallet """Test wallet instance.""" start_height: int """Blockchain start height.""" @@ -34,10 +36,10 @@ def is_notified(self) -> bool: """ return self.prev_height is not None - def __init__(self, wallet: MoneroWalletFull, start_height: int, end_height: int) -> None: + def __init__(self, wallet: MoneroWallet, start_height: int, end_height: int) -> None: """Initialize a new wallet sync progress tester. - :param MoneroWalletFull wallet: wallet to test. + :param MoneroWallet wallet: wallet to test. :param int start_height: wallet start height. :param int end_height: wallet end height. """ @@ -107,8 +109,9 @@ def on_done(self, chain_height: int) -> None: assert self.is_done is False self.is_done = True if self.prev_height is None: + logger.info("Wallet already synced") assert self.prev_complete_height is None - assert chain_height == self.start_height + assert chain_height == self.start_height, f"{chain_height} != {self.start_height}" else: # otherwise the last progress notification reports the final block assert chain_height - 1 == self.prev_height diff --git a/tests/utils/test_utils.py b/tests/utils/test_utils.py index fd425b7..7b2237e 100644 --- a/tests/utils/test_utils.py +++ b/tests/utils/test_utils.py @@ -9,14 +9,16 @@ MoneroNetworkType, MoneroWalletFull, MoneroRpcConnection, MoneroWalletConfig, MoneroDaemonRpc, MoneroWalletRpc, MoneroWallet, MoneroRpcError, MoneroWalletKeys, - MoneroUtils + MoneroUtils, MoneroWalletLight ) +from .string_utils import StringUtils from .wallet_sync_printer import WalletSyncPrinter from .wallet_tx_tracker import WalletTxTracker from .gen_utils import GenUtils from .daemon_utils import DaemonUtils from .docker_wallet_rpc_manager import DockerWalletRpcManager +from .monero_daemon_lws import MoneroDaemonLws logger: logging.Logger = logging.getLogger("TestUtils") @@ -45,8 +47,12 @@ class TestUtils(ABC): """Default wallet rpc used for tests.""" _WALLET_MINING: Optional[MoneroWalletFull] = None """Mining wallet used for funding test wallets.""" + _WALLET_LIGHT: Optional[MoneroWalletLight] = None + """Default wallet light used for tests.""" _DAEMON_RPC: Optional[MoneroDaemonRpc] = None """Default daemon rpc used for tests.""" + _DAEMON_LWS: Optional[MoneroDaemonLws] = None + """Default daemon lws used for tests.""" _MINING_DAEMON: Optional[MoneroDaemonRpc] = None """Internal daemon used for mining.""" _WALLET_RPC_2: Optional[MoneroWalletRpc] = None @@ -64,6 +70,12 @@ class TestUtils(ABC): """Monero daemon rpc zmq uri.""" DAEMON_RPC_ZMQ_PUB_URI: str = "" """Monero daemon rpc zmq pub uri.""" + + LWS_RPC_URI: str = "" + """Monero daemon lws rpc uri.""" + LWS_ADMIN_RPC_URI: str = "" + """Monero daemon lws admin rpc uri.""" + MAX_LWS_SUBADDRESSES: int = 200 TEST_NON_RELAYS: bool = True """Indicates if non-relays tests are enabled.""" TEST_RELAYS: bool = True @@ -203,6 +215,8 @@ def load_config(cls) -> None: cls.DAEMON_RPC_PASSWORD = parser.get('daemon', 'rpc_password') cls.DAEMON_RPC_ZMQ_URI = parser.get('daemon', 'zmq_uri') cls.DAEMON_RPC_ZMQ_PUB_URI = parser.get('daemon', 'zmq_pub_uri') + cls.LWS_RPC_URI = parser.get('daemon', 'lws_uri') + cls.LWS_ADMIN_RPC_URI = parser.get('daemon', 'lws_admin_uri') # parse wallet config cls.WALLET_NAME = parser.get('wallet', 'name') @@ -298,6 +312,18 @@ def get_daemon_rpc(cls) -> MoneroDaemonRpc: return cls._DAEMON_RPC + @classmethod + def get_daemon_lws(cls) -> MoneroDaemonLws: + """Get test daemon lws. + + :returns MoneroDaemonLws: test daemon lws instance. + """ + + if cls._DAEMON_LWS is None: + cls._DAEMON_LWS = MoneroDaemonLws(cls.LWS_RPC_URI, cls.LWS_ADMIN_RPC_URI) + + return cls._DAEMON_LWS + @classmethod def get_mining_daemon_rpc_connection(cls) -> MoneroRpcConnection: """Get the rpc connection of the daemon used for internal mining. @@ -326,6 +352,22 @@ def get_daemon_rpc_connection(cls) -> MoneroRpcConnection: """ return MoneroRpcConnection(cls.DAEMON_RPC_URI, cls.DAEMON_RPC_USERNAME, cls.DAEMON_RPC_PASSWORD) + @classmethod + def get_daemon_lws_connection(cls) -> MoneroRpcConnection: + """Get test daemon lws connection. + + :returns MoneroRpcConnection: new test daemon lws connection instance. + """ + return MoneroRpcConnection(cls.LWS_RPC_URI) + + @classmethod + def get_daemon_lws_admin_connection(cls) -> MoneroRpcConnection: + """Get test daemon lws admin connection. + + :returns MoneroRpcConnection: new test daemon lws admin connection instance. + """ + return MoneroRpcConnection(cls.LWS_ADMIN_RPC_URI) + @classmethod def get_wallet_keys_config(cls) -> MoneroWalletConfig: """Get test wallet keys configuration. @@ -541,6 +583,33 @@ def open_wallet_rpc(cls, c: Optional[MoneroWalletConfig]) -> MoneroWalletRpc: """ return cls.RPC_WALLET_MANAGER.open_wallet(c, cls.IN_CONTAINER) + @classmethod + def create_wallet_full(cls, config: Optional[MoneroWalletConfig], random: bool, start_syncing: bool = True) -> MoneroWalletFull: + # assign defaults + if config is None: + config = MoneroWalletConfig() + if config.path is None: + config.path = cls.TEST_WALLETS_DIR + "/" + StringUtils.get_random_string() + if config.password is None: + config.password = cls.WALLET_PASSWORD + if config.network_type is None: + config.network_type = cls.NETWORK_TYPE + if config.server is None: + config.server = cls.get_daemon_rpc_connection() + if config.restore_height is None and not random: + config.restore_height = 0 + + config.regtest = config.network_type == MoneroNetworkType.MAINNET and cls.REGTEST + + # create wallet + wallet = MoneroWalletFull.create_wallet(config) + if not random: + assert config.restore_height == wallet.get_restore_height() + if start_syncing is not False and wallet.is_connected_to_daemon(): + wallet.start_syncing(cls.SYNC_PERIOD_IN_MS) + + return wallet + @classmethod def create_wallet_rpc(cls, c: Optional[MoneroWalletConfig]) -> MoneroWalletRpc: """Create rpc wallet. @@ -550,6 +619,50 @@ def create_wallet_rpc(cls, c: Optional[MoneroWalletConfig]) -> MoneroWalletRpc: """ return cls.RPC_WALLET_MANAGER.create_wallet(c, cls.IN_CONTAINER) + @classmethod + def get_wallet_config_light(cls) -> MoneroWalletConfig: + """Get light wallet configuration. + + :returns MoneroWalletConfig: light wallet configuration. + """ + + config = MoneroWalletConfig() + config.path = cls.get_random_wallet_path() + config.password = cls.WALLET_PASSWORD + config.network_type = cls.NETWORK_TYPE + config.seed = cls.SEED + config.restore_height = cls.FIRST_RECEIVE_HEIGHT + config.regtest = cls.REGTEST + + config.account_lookahead = 1 + config.subaddress_lookahead = 11 + + return config + + @classmethod + def get_wallet_light(cls) -> MoneroWalletLight: + """Get test wallet light. + + :returns MoneroWalletLight: light test wallet. + """ + config: MoneroWalletConfig = cls.get_wallet_config_light() + rpc: MoneroRpcConnection = cls.get_daemon_lws_connection() + + if cls._WALLET_LIGHT is None or cls._WALLET_LIGHT.is_closed(): + if not MoneroWalletLight.wallet_exists(config, rpc): + # create wallet from seed if it doesn't exist + cls._WALLET_LIGHT = MoneroWalletLight.create_wallet(config, rpc) + else: + cls._WALLET_LIGHT = MoneroWalletLight.open_wallet(config, rpc) + + # sync and start background synchronizing with sync period, matching get_wallet_full()/get_wallet_rpc() + cls._WALLET_LIGHT.sync() + cls._WALLET_LIGHT.start_syncing(cls.SYNC_PERIOD_IN_MS) + logger.debug("Started light wallet background synchronizing") + logger.info("CREATED LIGHT WALLET: " + cls._WALLET_LIGHT.get_primary_address()) + + return cls._WALLET_LIGHT + @classmethod def get_all_rpc_connections(cls) -> list[MoneroRpcConnection]: """Get all daemon and wallets rpc connections used in tests (ordered by connection uri). diff --git a/tests/utils/wallet_send_utils.py b/tests/utils/wallet_send_utils.py index 11abf01..7803b01 100644 --- a/tests/utils/wallet_send_utils.py +++ b/tests/utils/wallet_send_utils.py @@ -10,6 +10,8 @@ from .wallet_sweeper import WalletSweeper from .send_and_update_txs_tester import SendAndUpdateTxsTester from .sync_with_pool_submit_tester import SyncWithPoolSubmitTester +from .tx_wallet_utils import TxWalletUtils +from .test_utils import TestUtils class WalletSendUtils(ABC): @@ -39,6 +41,10 @@ def test_send_from_multiple(cls, wallet: MoneroWallet, can_split: bool | None) - :param MoneroWallet wallet: test wallet to send txs from. :param bool can_split: can split wallet txs. """ + # this is needed for light wallet since lws can be stuck + TestUtils.WALLET_TX_TRACKER.wait_for_wallet_unlocked_balance( + wallet, FromMultipleTxSender.NUM_SUBADDRESSES + 2, TxWalletUtils.MAX_FEE + ) sender: FromMultipleTxSender = FromMultipleTxSender(wallet, can_split) sender.send() diff --git a/tests/utils/wallet_sweeper.py b/tests/utils/wallet_sweeper.py index 6280faf..dec1199 100644 --- a/tests/utils/wallet_sweeper.py +++ b/tests/utils/wallet_sweeper.py @@ -104,4 +104,6 @@ def sweep(self) -> None: ctx.is_sweep_response = True TxWalletUtils.test_tx_wallet(tx, ctx) + TestUtils.WALLET_TX_TRACKER.wait_for_txs_to_clear_pool(self._wallet) + self._check_outputs() diff --git a/tests/utils/wallet_test_utils.py b/tests/utils/wallet_test_utils.py index 1752ba8..040aa5f 100644 --- a/tests/utils/wallet_test_utils.py +++ b/tests/utils/wallet_test_utils.py @@ -6,7 +6,7 @@ from monero import ( MoneroNetworkType, MoneroUtils, MoneroAccount, MoneroSubaddress, MoneroWallet, MoneroTxConfig, MoneroDestination, - MoneroTxWallet, MoneroWalletFull, MoneroWalletRpc, + MoneroTxWallet, MoneroWalletFull, MoneroWalletRpc, MoneroWalletLight ) from .test_utils import TestUtils @@ -75,7 +75,7 @@ def is_wallet_funded(cls, wallet: MoneroWallet, xmr_amount_per_address: float, n amount_required: int = amount_required_per_account * num_accounts required_subaddresses: int = num_accounts * (num_subaddresses + 1) # include primary address - if not isinstance(wallet, MoneroWalletFull) and not isinstance(wallet, MoneroWalletRpc): + if not isinstance(wallet, (MoneroWalletFull, MoneroWalletRpc, MoneroWalletLight)): return False # sync wallet @@ -176,7 +176,7 @@ def fund_wallet( amount_required_str: str = f"{MoneroUtils.atomic_units_to_xmr(amount_required)} XMR" logger.debug(f"Funding wallet {primary_addr} with {amount_required_str}...") - supports_get_accounts: bool = isinstance(wallet, MoneroWalletRpc) or isinstance(wallet, MoneroWalletFull) + supports_get_accounts: bool = isinstance(wallet, MoneroWalletRpc) or isinstance(wallet, MoneroWalletFull) or isinstance(wallet, MoneroWalletLight) supports_save: bool = isinstance(wallet, MoneroWalletRpc) or isinstance(wallet, MoneroWalletFull) tx_config: MoneroTxConfig = cls.build_tx_config(wallet, num_accounts, num_subaddresses, amount_per_address, supports_get_accounts) diff --git a/tests/utils/wallet_tx_tracker.py b/tests/utils/wallet_tx_tracker.py index 3fa5d0c..9f32ba4 100644 --- a/tests/utils/wallet_tx_tracker.py +++ b/tests/utils/wallet_tx_tracker.py @@ -3,7 +3,7 @@ from time import sleep from monero import ( MoneroDaemon, MoneroWallet, MoneroTxQuery, MoneroSyncResult, - MoneroTxWallet, MoneroMiningStatus + MoneroTxWallet, MoneroMiningStatus, MoneroAccount ) logger: logging.Logger = logging.getLogger("WalletTxTracker") @@ -45,6 +45,17 @@ def __init__(self, daemon: MoneroDaemon, sync_period_ms: int, mining_address: st self._sync_period_ms = sync_period_ms self._mining_address = mining_address + @classmethod + def get_unlocked_accounts(cls, wallet: MoneroWallet, num_subaddresses: int, min_amount: int) -> list[MoneroAccount]: + accounts: list[MoneroAccount] = [] + for account in wallet.get_accounts(True): + for subaddress in account.subaddresses: + if subaddress.unlocked_balance is not None and subaddress.unlocked_balance > min_amount: + accounts.append(account) + if len(accounts) >= num_subaddresses: + break + return accounts + def _sleep(self) -> None: """Sleep for one sync period.""" sleep(self.sync_period) @@ -233,3 +244,42 @@ def wait_for_unlocked_balance( self._daemon.stop_mining() return unlocked_balance + + def wait_for_wallet_unlocked_balance( + self, wallet: MoneroWallet, + num_subaddresses: int, min_amount: int | None = None + ) -> None: + """Wait until some account has at least `num_subaddresses` subaddresses with unlocked balance. + + :param MoneroWallet wallet: Wallet to wait for unlocked balance. + :param int num_subaddresses: Minimum number of subaddresses with unlocked balance required within a single account. + :param int | None min_amount: Minimum unlocked balance per subaddress to count it (default 0). + :returns MoneroAccount: the first account found to satisfy the condition. + """ + if min_amount is None: + min_amount = 0 + + found: list[MoneroAccount] = self.get_unlocked_accounts(wallet, num_subaddresses, min_amount) + if len(found) > 0: + logger.debug(f"Wallet already has an account with {num_subaddresses} unlocked subaddresses") + return + + # start mining + mining_started: bool = False + if not self._daemon.get_mining_status().is_active: + try: + self._daemon.start_mining(self._mining_address, 1, False, False) + mining_started = True + except Exception as e: + logger.warning(f"An error occurred while starting mining: {str(e)}") + # no problem + + logger.info(f"Waiting for an account with {num_subaddresses} unlocked subaddresses") + while len(found) == 0: + self._sleep() + found = self.get_unlocked_accounts(wallet, num_subaddresses, min_amount) + + # stop mining if started + if mining_started: + self._daemon.stop_mining() + diff --git a/tests/utils/wallet_type.py b/tests/utils/wallet_type.py index 5fda184..c442182 100644 --- a/tests/utils/wallet_type.py +++ b/tests/utils/wallet_type.py @@ -13,5 +13,8 @@ class WalletType(IntEnum): FULL = 2 """Full local wallet.""" + LIGHT = 3 + """Light wallet.""" + UNDEFINED = 255 """Invalid wallet type."""